···1package handlers
23import (
4+ "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8+ "html/template"
9 "log/slog"
10 "net/http"
11 "net/url"
12 "strings"
13+ "sync"
14 "time"
1516 "atcr.io/pkg/atproto"
···126 slog.Warn("Failed to render vuln badge", "error", err)
127 }
128}
129+130+// fetchScanRecord fetches a scan record from a hold's PDS and returns badge data.
131+func fetchScanRecord(ctx context.Context, holdEndpoint, holdDID, hexDigest string) vulnBadgeData {
132+ rkey := hexDigest
133+ fullDigest := "sha256:" + hexDigest
134+135+ scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
136+ holdEndpoint,
137+ url.QueryEscape(holdDID),
138+ url.QueryEscape(atproto.ScanCollection),
139+ url.QueryEscape(rkey),
140+ )
141+142+ req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
143+ if err != nil {
144+ return vulnBadgeData{Error: true}
145+ }
146+147+ resp, err := http.DefaultClient.Do(req)
148+ if err != nil {
149+ return vulnBadgeData{Error: true}
150+ }
151+ defer resp.Body.Close()
152+153+ if resp.StatusCode != http.StatusOK {
154+ return vulnBadgeData{Error: true}
155+ }
156+157+ var envelope struct {
158+ Value json.RawMessage `json:"value"`
159+ }
160+ if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
161+ return vulnBadgeData{Error: true}
162+ }
163+164+ var scanRecord atproto.ScanRecord
165+ if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
166+ return vulnBadgeData{Error: true}
167+ }
168+169+ return vulnBadgeData{
170+ Critical: scanRecord.Critical,
171+ High: scanRecord.High,
172+ Medium: scanRecord.Medium,
173+ Low: scanRecord.Low,
174+ Total: scanRecord.Total,
175+ ScannedAt: scanRecord.ScannedAt,
176+ Found: true,
177+ Digest: fullDigest,
178+ HoldEndpoint: holdEndpoint,
179+ }
180+}
181+182+// BatchScanResultHandler handles a single HTMX request that fetches scan results
183+// for multiple manifests concurrently and returns OOB swap fragments.
184+type BatchScanResultHandler struct {
185+ BaseUIHandler
186+}
187+188+func (h *BatchScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
189+ holdEndpoint := r.URL.Query().Get("holdEndpoint")
190+ digestsParam := r.URL.Query().Get("digests")
191+192+ if holdEndpoint == "" || digestsParam == "" {
193+ w.Header().Set("Content-Type", "text/html")
194+ return
195+ }
196+197+ digests := strings.Split(digestsParam, ",")
198+ if len(digests) > 50 {
199+ digests = digests[:50]
200+ }
201+202+ holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
203+ if holdDID == "" {
204+ // Can't resolve hold — render empty OOB spans
205+ w.Header().Set("Content-Type", "text/html")
206+ for _, d := range digests {
207+ fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d))
208+ }
209+ return
210+ }
211+212+ // Fetch scan records concurrently with a semaphore to limit parallelism
213+ type result struct {
214+ hexDigest string
215+ data vulnBadgeData
216+ }
217+ results := make([]result, len(digests))
218+ sem := make(chan struct{}, 10)
219+220+ var wg sync.WaitGroup
221+ for i, hexDigest := range digests {
222+ results[i].hexDigest = hexDigest
223+ wg.Add(1)
224+ go func(idx int, hex string) {
225+ defer wg.Done()
226+ sem <- struct{}{}
227+ defer func() { <-sem }()
228+229+ ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
230+ defer cancel()
231+232+ results[idx].data = fetchScanRecord(ctx, holdEndpoint, holdDID, hex)
233+ }(i, hexDigest)
234+ }
235+ wg.Wait()
236+237+ // Render all OOB fragments
238+ w.Header().Set("Content-Type", "text/html")
239+ for _, res := range results {
240+ var buf bytes.Buffer
241+ if err := h.Templates.ExecuteTemplate(&buf, "vuln-badge", res.data); err != nil {
242+ slog.Warn("Failed to render vuln badge in batch", "digest", res.hexDigest, "error", err)
243+ }
244+ fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML">%s</span>`,
245+ template.HTMLEscapeString(res.hexDigest), buf.String())
246+ }
247+}
+147
pkg/appview/handlers/scan_result_test.go
···253 t.Error("Should not contain 'L:0' for zero low count")
254 }
255}
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···253 t.Error("Should not contain 'L:0' for zero low count")
254 }
255}
256+257+// --- Batch scan result tests ---
258+259+func setupBatchScanResultHandler(t *testing.T) *handlers.BatchScanResultHandler {
260+ t.Helper()
261+ templates, err := appview.Templates(nil)
262+ if err != nil {
263+ t.Fatalf("Failed to load templates: %v", err)
264+ }
265+ return &handlers.BatchScanResultHandler{
266+ BaseUIHandler: handlers.BaseUIHandler{
267+ Templates: templates,
268+ },
269+ }
270+}
271+272+func TestBatchScanResult_MultipleDigests(t *testing.T) {
273+ // Mock hold that returns different results based on rkey
274+ hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
275+ rkey := r.URL.Query().Get("rkey")
276+ w.Header().Set("Content-Type", "application/json")
277+ switch rkey {
278+ case "abc123":
279+ w.Write([]byte(mockScanRecord(2, 5, 10, 3, 20)))
280+ case "def456":
281+ w.Write([]byte(mockScanRecord(0, 0, 0, 0, 0)))
282+ default:
283+ http.Error(w, "not found", http.StatusNotFound)
284+ }
285+ }))
286+ defer hold.Close()
287+288+ handler := setupBatchScanResultHandler(t)
289+290+ req := httptest.NewRequest("GET",
291+ "/api/scan-results?holdEndpoint="+hold.URL+"&digests=abc123,def456,unknown789", nil)
292+ rr := httptest.NewRecorder()
293+ handler.ServeHTTP(rr, req)
294+295+ if rr.Code != http.StatusOK {
296+ t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
297+ }
298+299+ body := rr.Body.String()
300+301+ // All three digests should have OOB spans
302+ if !strings.Contains(body, `id="scan-badge-abc123"`) {
303+ t.Error("Expected OOB span for abc123")
304+ }
305+ if !strings.Contains(body, `id="scan-badge-def456"`) {
306+ t.Error("Expected OOB span for def456")
307+ }
308+ if !strings.Contains(body, `id="scan-badge-unknown789"`) {
309+ t.Error("Expected OOB span for unknown789")
310+ }
311+312+ // All should have hx-swap-oob attribute
313+ if !strings.Contains(body, `hx-swap-oob="outerHTML"`) {
314+ t.Error("Expected hx-swap-oob attribute in response")
315+ }
316+317+ // abc123 should have vulnerability badges
318+ if !strings.Contains(body, "C:2") {
319+ t.Error("Expected body to contain 'C:2' for abc123")
320+ }
321+ // def456 should have clean badge
322+ if !strings.Contains(body, "Clean") {
323+ t.Error("Expected body to contain 'Clean' for def456")
324+ }
325+}
326+327+func TestBatchScanResult_EmptyParams(t *testing.T) {
328+ handler := setupBatchScanResultHandler(t)
329+330+ // No params
331+ req := httptest.NewRequest("GET", "/api/scan-results", nil)
332+ rr := httptest.NewRecorder()
333+ handler.ServeHTTP(rr, req)
334+335+ body := strings.TrimSpace(rr.Body.String())
336+ if body != "" {
337+ t.Errorf("Expected empty body for missing params, got: %q", body)
338+ }
339+}
340+341+func TestBatchScanResult_MissingDigests(t *testing.T) {
342+ handler := setupBatchScanResultHandler(t)
343+344+ req := httptest.NewRequest("GET", "/api/scan-results?holdEndpoint=https://hold.example.com", nil)
345+ rr := httptest.NewRecorder()
346+ handler.ServeHTTP(rr, req)
347+348+ body := strings.TrimSpace(rr.Body.String())
349+ if body != "" {
350+ t.Errorf("Expected empty body for missing digests, got: %q", body)
351+ }
352+}
353+354+func TestBatchScanResult_HoldUnreachable(t *testing.T) {
355+ hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
356+ hold.Close()
357+358+ handler := setupBatchScanResultHandler(t)
359+360+ req := httptest.NewRequest("GET",
361+ "/api/scan-results?holdEndpoint="+hold.URL+"&digests=abc123,def456", nil)
362+ rr := httptest.NewRecorder()
363+ handler.ServeHTTP(rr, req)
364+365+ body := rr.Body.String()
366+367+ // Should still have OOB spans (empty content since hold is unreachable)
368+ if !strings.Contains(body, `id="scan-badge-abc123"`) {
369+ t.Error("Expected OOB span for abc123 even when hold is unreachable")
370+ }
371+ if !strings.Contains(body, `id="scan-badge-def456"`) {
372+ t.Error("Expected OOB span for def456 even when hold is unreachable")
373+ }
374+ // Should NOT contain vulnerability badges
375+ if strings.Contains(body, "badge-error") || strings.Contains(body, "Clean") {
376+ t.Error("Unreachable hold should not render badge content")
377+ }
378+}
379+380+func TestBatchScanResult_SingleDigest(t *testing.T) {
381+ hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
382+ w.Header().Set("Content-Type", "application/json")
383+ w.Write([]byte(mockScanRecord(1, 0, 0, 0, 1)))
384+ }))
385+ defer hold.Close()
386+387+ handler := setupBatchScanResultHandler(t)
388+389+ req := httptest.NewRequest("GET",
390+ "/api/scan-results?holdEndpoint="+hold.URL+"&digests=abc123", nil)
391+ rr := httptest.NewRecorder()
392+ handler.ServeHTTP(rr, req)
393+394+ body := rr.Body.String()
395+396+ if !strings.Contains(body, `id="scan-badge-abc123"`) {
397+ t.Error("Expected OOB span for abc123")
398+ }
399+ if !strings.Contains(body, "C:1") {
400+ t.Error("Expected body to contain 'C:1'")
401+ }
402+}
+1
pkg/appview/routes/routes.go
···124125 // Vulnerability scan result API endpoints (HTMX lazy loading + modal content)
126 router.Get("/api/scan-result", (&uihandlers.ScanResultHandler{BaseUIHandler: base}).ServeHTTP)
0127 router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP)
128129 // Attestation details API endpoint (HTMX modal content)