mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
remove distribution from hold, add vulnerability scanning in appview.
1. Removing distribution/distribution from the Hold Service (biggest change) The hold service previously used distribution's StorageDriver interface for all blob operations. This replaces it with direct AWS SDK v2 calls through ATCR's own pkg/s3.S3Service: - New S3Service methods: Stat(), PutBytes(), Move(), Delete(), WalkBlobs(), ListPrefix() added to pkg/s3/types.go - Pull zone fix: Presigned URLs are now generated against the real S3 endpoint, then the host is swapped to the CDN URL post-signing (previously the CDN URL was set as the endpoint, which broke SigV4 signatures) - All hold subsystems migrated: GC, OCI uploads, XRPC handlers, profile uploads, scan broadcaster, manifest posts — all now use *s3.S3Service instead of storagedriver.StorageDriver - Config simplified: Removed configuration.Storage type and buildStorageConfigFromFields(); replaced with a simple S3Params() method - Mock expanded: MockS3Client gains an in-memory object store + 5 new methods, replacing duplicate mockStorageDriver implementations in tests (~160 lines deleted from each test file) 2. Vulnerability Scan UI in AppView (new feature) Displays scan results from the hold's PDS on the repository page: - New lexicon: io/atcr/hold/scan.json with vulnReportBlob field for storing full Grype reports - Two new HTMX endpoints: /api/scan-result (badge) and /api/vuln-details (modal with CVE table) - New templates: vuln-badge.html (severity count chips) and vuln-details.html (full CVE table with NVD/GHSA links) - Repository page: Lazy-loads scan badges per manifest via HTMX - Tests: ~590 lines of test coverage for both handlers 3. S3 Diagnostic Tool New cmd/s3-test/main.go (418 lines) — tests S3 connectivity with both SDK v1 and v2, including presigned URL generation, pull zone host swapping, and verbose signing debug output. 4. Deployment Tooling - New syncServiceUnit() for comparing/updating systemd units on servers - Update command now syncs config keys (adds missing keys from template) and service units with daemon-reload 5. DB Migration 0011_fix_captain_successor_column.yaml — rebuilds hold_captain_records to add the successor column that was missed in a previous migration. 6. Documentation - APPVIEW-UI-FUTURE.md rewritten as a status-tracked feature inventory - DISTRIBUTION.md renamed to CREDENTIAL_HELPER.md - New REMOVING_DISTRIBUTION.md — 480-line analysis of fully removing distribution from the appview side 7. go.mod aws-sdk-go v1 moved from indirect to direct (needed by cmd/s3-test).
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
description: Rebuild hold_captain_records to match schema.sql (provider→successor rename was missed when migration 0010 was recorded but not executed on a fresh DB)
|
||||
query: |
|
||||
-- Recreate table to match schema.sql exactly
|
||||
CREATE TABLE IF NOT EXISTS hold_captain_records_new (
|
||||
hold_did TEXT PRIMARY KEY,
|
||||
owner_did TEXT NOT NULL,
|
||||
public BOOLEAN NOT NULL,
|
||||
allow_all_crew BOOLEAN NOT NULL,
|
||||
deployed_at TEXT,
|
||||
region TEXT,
|
||||
successor TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Copy data (only guaranteed-common columns; successor will be NULL)
|
||||
INSERT OR IGNORE INTO hold_captain_records_new (hold_did, owner_did, public, allow_all_crew, deployed_at, region, updated_at)
|
||||
SELECT hold_did, owner_did, public, allow_all_crew, deployed_at, region, updated_at
|
||||
FROM hold_captain_records;
|
||||
|
||||
-- Swap tables
|
||||
DROP TABLE hold_captain_records;
|
||||
ALTER TABLE hold_captain_records_new RENAME TO hold_captain_records;
|
||||
|
||||
-- Recreate index
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// ScanResultHandler handles HTMX requests for vulnerability scan badges.
|
||||
// Returns an HTML fragment (vuln-badge partial) that replaces the placeholder span.
|
||||
type ScanResultHandler struct {
|
||||
BaseUIHandler
|
||||
}
|
||||
|
||||
// vulnBadgeData is the template data for the vuln-badge partial.
|
||||
type vulnBadgeData struct {
|
||||
Critical int64
|
||||
High int64
|
||||
Medium int64
|
||||
Low int64
|
||||
Total int64
|
||||
ScannedAt string
|
||||
Found bool // true if scan record exists
|
||||
Error bool // true if hold unreachable or error
|
||||
Digest string // for the detail modal link
|
||||
HoldEndpoint string // for the detail modal link
|
||||
}
|
||||
|
||||
func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
digest := r.URL.Query().Get("digest")
|
||||
holdEndpoint := r.URL.Query().Get("holdEndpoint")
|
||||
|
||||
if digest == "" || holdEndpoint == "" {
|
||||
// Missing params — render nothing
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
return
|
||||
}
|
||||
|
||||
// Derive hold DID from endpoint URL
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
|
||||
if holdDID == "" {
|
||||
h.renderBadge(w, vulnBadgeData{Error: true})
|
||||
return
|
||||
}
|
||||
|
||||
// Compute rkey from digest (strip sha256: prefix)
|
||||
rkey := strings.TrimPrefix(digest, "sha256:")
|
||||
|
||||
// Fetch scan record from hold's PDS
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
|
||||
holdEndpoint,
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape(atproto.ScanCollection),
|
||||
url.QueryEscape(rkey),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
|
||||
if err != nil {
|
||||
h.renderBadge(w, vulnBadgeData{Error: true})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
// Hold unreachable or timeout — render nothing
|
||||
h.renderBadge(w, vulnBadgeData{Error: true})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// No scan record — scanning disabled or not yet scanned. Render nothing.
|
||||
h.renderBadge(w, vulnBadgeData{Error: true})
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.renderBadge(w, vulnBadgeData{Error: true})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the getRecord response envelope
|
||||
var envelope struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
h.renderBadge(w, vulnBadgeData{Error: true})
|
||||
return
|
||||
}
|
||||
|
||||
var scanRecord atproto.ScanRecord
|
||||
if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
|
||||
h.renderBadge(w, vulnBadgeData{Error: true})
|
||||
return
|
||||
}
|
||||
|
||||
h.renderBadge(w, vulnBadgeData{
|
||||
Critical: scanRecord.Critical,
|
||||
High: scanRecord.High,
|
||||
Medium: scanRecord.Medium,
|
||||
Low: scanRecord.Low,
|
||||
Total: scanRecord.Total,
|
||||
ScannedAt: scanRecord.ScannedAt,
|
||||
Found: true,
|
||||
Digest: digest,
|
||||
HoldEndpoint: holdEndpoint,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ScanResultHandler) renderBadge(w http.ResponseWriter, data vulnBadgeData) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "vuln-badge", data); err != nil {
|
||||
slog.Warn("Failed to render vuln badge", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/appview/handlers"
|
||||
)
|
||||
|
||||
// mockScanRecord returns a getRecord JSON envelope wrapping a scan record
|
||||
func mockScanRecord(critical, high, medium, low, total int64) string {
|
||||
record := map[string]any{
|
||||
"$type": "io.atcr.hold.scan",
|
||||
"manifest": "at://did:plc:test/io.atcr.manifest/abc123",
|
||||
"repository": "myapp",
|
||||
"userDid": "did:plc:test",
|
||||
"critical": critical,
|
||||
"high": high,
|
||||
"medium": medium,
|
||||
"low": low,
|
||||
"total": total,
|
||||
"scannerVersion": "atcr-scanner-v1.0.0",
|
||||
"scannedAt": "2025-01-15T10:30:00Z",
|
||||
}
|
||||
envelope := map[string]any{
|
||||
"uri": "at://did:web:hold.example.com/io.atcr.hold.scan/abc123",
|
||||
"cid": "bafyreiabc123",
|
||||
"value": record,
|
||||
}
|
||||
b, _ := json.Marshal(envelope)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func setupScanResultHandler(t *testing.T, holdURL string) *handlers.ScanResultHandler {
|
||||
t.Helper()
|
||||
templates, err := appview.Templates(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load templates: %v", err)
|
||||
}
|
||||
return &handlers.ScanResultHandler{
|
||||
BaseUIHandler: handlers.BaseUIHandler{
|
||||
Templates: templates,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_WithVulnerabilities(t *testing.T) {
|
||||
// Mock hold that returns a scan record with vulnerabilities
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(mockScanRecord(2, 5, 10, 3, 20)))
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupScanResultHandler(t, hold.URL)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Should contain severity badges
|
||||
if !strings.Contains(body, "badge-error") {
|
||||
t.Error("Expected body to contain badge-error for critical vulnerabilities")
|
||||
}
|
||||
if !strings.Contains(body, "C:2") {
|
||||
t.Error("Expected body to contain 'C:2' for critical count")
|
||||
}
|
||||
if !strings.Contains(body, "badge-warning") {
|
||||
t.Error("Expected body to contain badge-warning for high vulnerabilities")
|
||||
}
|
||||
if !strings.Contains(body, "H:5") {
|
||||
t.Error("Expected body to contain 'H:5' for high count")
|
||||
}
|
||||
if !strings.Contains(body, "M:10") {
|
||||
t.Error("Expected body to contain 'M:10' for medium count")
|
||||
}
|
||||
if !strings.Contains(body, "L:3") {
|
||||
t.Error("Expected body to contain 'L:3' for low count")
|
||||
}
|
||||
// Should be clickable (has openVulnDetails)
|
||||
if !strings.Contains(body, "openVulnDetails") {
|
||||
t.Error("Expected body to contain openVulnDetails click handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_Clean(t *testing.T) {
|
||||
// Mock hold that returns a scan record with zero vulnerabilities
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(mockScanRecord(0, 0, 0, 0, 0)))
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupScanResultHandler(t, hold.URL)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
if !strings.Contains(body, "Clean") {
|
||||
t.Error("Expected body to contain 'Clean' for zero-vulnerability scan")
|
||||
}
|
||||
if !strings.Contains(body, "badge-success") {
|
||||
t.Error("Expected body to contain badge-success for clean scan")
|
||||
}
|
||||
// Should NOT be clickable
|
||||
if strings.Contains(body, "openVulnDetails") {
|
||||
t.Error("Clean badge should not have openVulnDetails click handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_NotFound(t *testing.T) {
|
||||
// Mock hold that returns 404 (no scan record — scanning disabled or not yet scanned)
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "record not found", http.StatusNotFound)
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupScanResultHandler(t, hold.URL)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := strings.TrimSpace(rr.Body.String())
|
||||
|
||||
// 404 = no scan record. Should render NOTHING — not "Scan pending".
|
||||
if body != "" {
|
||||
t.Errorf("Expected empty body for 404, got: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_HoldError(t *testing.T) {
|
||||
// Mock hold that returns 500
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupScanResultHandler(t, hold.URL)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := strings.TrimSpace(rr.Body.String())
|
||||
|
||||
if body != "" {
|
||||
t.Errorf("Expected empty body for hold error, got: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_HoldUnreachable(t *testing.T) {
|
||||
// Use a server that's already closed (unreachable)
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
hold.Close()
|
||||
|
||||
handler := setupScanResultHandler(t, hold.URL)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := strings.TrimSpace(rr.Body.String())
|
||||
|
||||
if body != "" {
|
||||
t.Errorf("Expected empty body for unreachable hold, got: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_MissingParams(t *testing.T) {
|
||||
handler := setupScanResultHandler(t, "")
|
||||
|
||||
// No params at all
|
||||
req := httptest.NewRequest("GET", "/api/scan-result", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := strings.TrimSpace(rr.Body.String())
|
||||
|
||||
if body != "" {
|
||||
t.Errorf("Expected empty body for missing params, got: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_MissingDigest(t *testing.T) {
|
||||
handler := setupScanResultHandler(t, "")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?holdEndpoint=https://hold.example.com", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := strings.TrimSpace(rr.Body.String())
|
||||
|
||||
if body != "" {
|
||||
t.Errorf("Expected empty body for missing digest, got: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_MissingHoldEndpoint(t *testing.T) {
|
||||
handler := setupScanResultHandler(t, "")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := strings.TrimSpace(rr.Body.String())
|
||||
|
||||
if body != "" {
|
||||
t.Errorf("Expected empty body for missing holdEndpoint, got: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResult_OnlyCriticalShown(t *testing.T) {
|
||||
// Only critical vulns, no high/medium/low
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(mockScanRecord(3, 0, 0, 0, 3)))
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupScanResultHandler(t, hold.URL)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
if !strings.Contains(body, "C:3") {
|
||||
t.Error("Expected body to contain 'C:3'")
|
||||
}
|
||||
// Zero-count badges should NOT appear
|
||||
if strings.Contains(body, "H:0") {
|
||||
t.Error("Should not contain 'H:0' for zero high count")
|
||||
}
|
||||
if strings.Contains(body, "M:0") {
|
||||
t.Error("Should not contain 'M:0' for zero medium count")
|
||||
}
|
||||
if strings.Contains(body, "L:0") {
|
||||
t.Error("Should not contain 'L:0' for zero low count")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// VulnDetailsHandler handles requests for the vulnerability detail modal content.
|
||||
// Returns an HTML fragment (vuln-details partial) for insertion into the modal body.
|
||||
type VulnDetailsHandler struct {
|
||||
BaseUIHandler
|
||||
}
|
||||
|
||||
// grypeReport is the minimal Grype JSON structure we need.
|
||||
type grypeReport struct {
|
||||
Matches []grypeMatch `json:"matches"`
|
||||
}
|
||||
|
||||
type grypeMatch struct {
|
||||
Vulnerability grypeVuln `json:"vulnerability"`
|
||||
Artifact grypeArtifact `json:"artifact"`
|
||||
}
|
||||
|
||||
type grypeVuln struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Fix grypeFix `json:"fix"`
|
||||
}
|
||||
|
||||
type grypeFix struct {
|
||||
Versions []string `json:"versions"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type grypeArtifact struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// vulnDetailsData is the template data for the vuln-details partial.
|
||||
type vulnDetailsData struct {
|
||||
Matches []vulnMatch
|
||||
Summary vulnSummary
|
||||
Error string // non-empty if something went wrong
|
||||
ScannedAt string
|
||||
}
|
||||
|
||||
type vulnMatch struct {
|
||||
CVEURL string
|
||||
CVEID string
|
||||
Severity string // Critical, High, Medium, Low, Negligible, Unknown
|
||||
Package string
|
||||
Version string
|
||||
FixedIn string
|
||||
Type string // deb, npm, gem, etc.
|
||||
}
|
||||
|
||||
type vulnSummary struct {
|
||||
Critical int64
|
||||
High int64
|
||||
Medium int64
|
||||
Low int64
|
||||
Total int64
|
||||
}
|
||||
|
||||
// severityOrder maps severity strings to sort order (lower = more severe).
|
||||
var severityOrder = map[string]int{
|
||||
"Critical": 0,
|
||||
"High": 1,
|
||||
"Medium": 2,
|
||||
"Low": 3,
|
||||
"Negligible": 4,
|
||||
"Unknown": 5,
|
||||
}
|
||||
|
||||
func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
digest := r.URL.Query().Get("digest")
|
||||
holdEndpoint := r.URL.Query().Get("holdEndpoint")
|
||||
|
||||
if digest == "" || holdEndpoint == "" {
|
||||
h.renderDetails(w, vulnDetailsData{Error: "Missing required parameters"})
|
||||
return
|
||||
}
|
||||
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
|
||||
if holdDID == "" {
|
||||
h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold identity"})
|
||||
return
|
||||
}
|
||||
|
||||
rkey := strings.TrimPrefix(digest, "sha256:")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Step 1: Fetch the scan record to get the VulnReportBlob CID
|
||||
scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
|
||||
holdEndpoint,
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape(atproto.ScanCollection),
|
||||
url.QueryEscape(rkey),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
|
||||
if err != nil {
|
||||
h.renderDetails(w, vulnDetailsData{Error: "Failed to build request"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.renderDetails(w, vulnDetailsData{Error: "Hold service unreachable"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.renderDetails(w, vulnDetailsData{Error: "No scan record found"})
|
||||
return
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
h.renderDetails(w, vulnDetailsData{Error: "Failed to parse scan record"})
|
||||
return
|
||||
}
|
||||
|
||||
var scanRecord atproto.ScanRecord
|
||||
if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
|
||||
h.renderDetails(w, vulnDetailsData{Error: "Failed to parse scan record"})
|
||||
return
|
||||
}
|
||||
|
||||
summary := vulnSummary{
|
||||
Critical: scanRecord.Critical,
|
||||
High: scanRecord.High,
|
||||
Medium: scanRecord.Medium,
|
||||
Low: scanRecord.Low,
|
||||
Total: scanRecord.Total,
|
||||
}
|
||||
|
||||
// Step 2: Fetch the vulnerability report blob
|
||||
if scanRecord.VulnReportBlob == nil || scanRecord.VulnReportBlob.Ref.String() == "" {
|
||||
h.renderDetails(w, vulnDetailsData{
|
||||
Summary: summary,
|
||||
ScannedAt: scanRecord.ScannedAt,
|
||||
Error: "No detailed vulnerability report available. Only summary counts were recorded.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
blobCID := scanRecord.VulnReportBlob.Ref.String()
|
||||
blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
|
||||
holdEndpoint,
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape(blobCID),
|
||||
)
|
||||
|
||||
blobReq, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil)
|
||||
if err != nil {
|
||||
h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to build blob request"})
|
||||
return
|
||||
}
|
||||
|
||||
blobResp, err := http.DefaultClient.Do(blobReq)
|
||||
if err != nil {
|
||||
h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to fetch vulnerability report"})
|
||||
return
|
||||
}
|
||||
defer blobResp.Body.Close()
|
||||
|
||||
if blobResp.StatusCode != http.StatusOK {
|
||||
h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Vulnerability report not accessible"})
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Parse the Grype JSON
|
||||
var report grypeReport
|
||||
if err := json.NewDecoder(blobResp.Body).Decode(&report); err != nil {
|
||||
h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to parse vulnerability report"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to template data
|
||||
matches := make([]vulnMatch, 0, len(report.Matches))
|
||||
for _, m := range report.Matches {
|
||||
fixedIn := ""
|
||||
if len(m.Vulnerability.Fix.Versions) > 0 {
|
||||
fixedIn = strings.Join(m.Vulnerability.Fix.Versions, ", ")
|
||||
}
|
||||
|
||||
cveURL := ""
|
||||
if strings.HasPrefix(m.Vulnerability.ID, "CVE-") {
|
||||
cveURL = "https://nvd.nist.gov/vuln/detail/" + m.Vulnerability.ID
|
||||
} else if strings.HasPrefix(m.Vulnerability.ID, "GHSA-") {
|
||||
cveURL = "https://github.com/advisories/" + m.Vulnerability.ID
|
||||
}
|
||||
|
||||
matches = append(matches, vulnMatch{
|
||||
CVEID: m.Vulnerability.ID,
|
||||
CVEURL: cveURL,
|
||||
Severity: m.Vulnerability.Severity,
|
||||
Package: m.Artifact.Name,
|
||||
Version: m.Artifact.Version,
|
||||
FixedIn: fixedIn,
|
||||
Type: m.Artifact.Type,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by severity (critical first)
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
oi := severityOrder[matches[i].Severity]
|
||||
oj := severityOrder[matches[j].Severity]
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return matches[i].CVEID < matches[j].CVEID
|
||||
})
|
||||
|
||||
h.renderDetails(w, vulnDetailsData{
|
||||
Matches: matches,
|
||||
Summary: summary,
|
||||
ScannedAt: scanRecord.ScannedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *VulnDetailsHandler) renderDetails(w http.ResponseWriter, data vulnDetailsData) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "vuln-details", data); err != nil {
|
||||
slog.Warn("Failed to render vuln details", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/appview/handlers"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/multiformats/go-multihash"
|
||||
)
|
||||
|
||||
// mockGrypeReport returns a minimal Grype JSON report
|
||||
func mockGrypeReport() string {
|
||||
report := map[string]any{
|
||||
"matches": []map[string]any{
|
||||
{
|
||||
"vulnerability": map[string]any{
|
||||
"id": "CVE-2024-1234",
|
||||
"severity": "Critical",
|
||||
"fix": map[string]any{"versions": []string{"1.2.4"}, "state": "fixed"},
|
||||
},
|
||||
"artifact": map[string]any{
|
||||
"name": "libssl",
|
||||
"version": "1.1.1",
|
||||
"type": "deb",
|
||||
},
|
||||
},
|
||||
{
|
||||
"vulnerability": map[string]any{
|
||||
"id": "CVE-2024-5678",
|
||||
"severity": "Low",
|
||||
"fix": map[string]any{"versions": []string{}, "state": "not-fixed"},
|
||||
},
|
||||
"artifact": map[string]any{
|
||||
"name": "zlib",
|
||||
"version": "1.2.11",
|
||||
"type": "deb",
|
||||
},
|
||||
},
|
||||
{
|
||||
"vulnerability": map[string]any{
|
||||
"id": "GHSA-abcd-efgh-ijkl",
|
||||
"severity": "High",
|
||||
"fix": map[string]any{"versions": []string{"2.0.0"}, "state": "fixed"},
|
||||
},
|
||||
"artifact": map[string]any{
|
||||
"name": "express",
|
||||
"version": "4.17.1",
|
||||
"type": "npm",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(report)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// testCID creates a valid CIDv1 from arbitrary data (for test fixtures)
|
||||
func testCID(data string) cid.Cid {
|
||||
hash := sha256.Sum256([]byte(data))
|
||||
mh, _ := multihash.Encode(hash[:], multihash.SHA2_256)
|
||||
return cid.NewCidV1(0x55, mh) // raw codec
|
||||
}
|
||||
|
||||
// mockScanRecordWithBlob returns a getRecord envelope with a VulnReportBlob reference.
|
||||
// Uses a real CID so LexBlob JSON unmarshaling works correctly.
|
||||
func mockScanRecordWithBlob(critical, high, medium, low, total int64) string {
|
||||
blobCID := testCID("test-vuln-report")
|
||||
|
||||
// Build the record using the actual LexBlob type for correct JSON format
|
||||
blob := &lexutil.LexBlob{
|
||||
Ref: lexutil.LexLink(blobCID),
|
||||
MimeType: "application/vnd.atcr.vulnerabilities+json",
|
||||
Size: 12345,
|
||||
}
|
||||
|
||||
// Marshal blob separately to get the canonical JSON format
|
||||
blobJSON, _ := json.Marshal(blob)
|
||||
|
||||
// Build the full record as a map, inserting the pre-marshaled blob
|
||||
record := map[string]json.RawMessage{
|
||||
"$type": jsonStr("io.atcr.hold.scan"),
|
||||
"manifest": jsonStr("at://did:plc:test/io.atcr.manifest/abc123"),
|
||||
"repository": jsonStr("myapp"),
|
||||
"userDid": jsonStr("did:plc:test"),
|
||||
"vulnReportBlob": blobJSON,
|
||||
"critical": jsonInt(critical),
|
||||
"high": jsonInt(high),
|
||||
"medium": jsonInt(medium),
|
||||
"low": jsonInt(low),
|
||||
"total": jsonInt(total),
|
||||
"scannerVersion": jsonStr("atcr-scanner-v1.0.0"),
|
||||
"scannedAt": jsonStr("2025-01-15T10:30:00Z"),
|
||||
}
|
||||
recordJSON, _ := json.Marshal(record)
|
||||
|
||||
envelope := map[string]any{
|
||||
"uri": "at://did:web:hold.example.com/io.atcr.hold.scan/abc123",
|
||||
"cid": "bafyreiabc123",
|
||||
"value": json.RawMessage(recordJSON),
|
||||
}
|
||||
b, _ := json.Marshal(envelope)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func jsonStr(s string) json.RawMessage {
|
||||
b, _ := json.Marshal(s)
|
||||
return b
|
||||
}
|
||||
|
||||
func jsonInt(n int64) json.RawMessage {
|
||||
b, _ := json.Marshal(n)
|
||||
return b
|
||||
}
|
||||
|
||||
// mockScanRecordWithoutBlob returns a getRecord envelope without VulnReportBlob
|
||||
func mockScanRecordWithoutBlob(critical, high, medium, low, total int64) string {
|
||||
record := map[string]any{
|
||||
"$type": "io.atcr.hold.scan",
|
||||
"manifest": "at://did:plc:test/io.atcr.manifest/abc123",
|
||||
"repository": "myapp",
|
||||
"userDid": "did:plc:test",
|
||||
"critical": critical,
|
||||
"high": high,
|
||||
"medium": medium,
|
||||
"low": low,
|
||||
"total": total,
|
||||
"scannerVersion": "atcr-scanner-v1.0.0",
|
||||
"scannedAt": "2025-01-15T10:30:00Z",
|
||||
}
|
||||
envelope := map[string]any{
|
||||
"uri": "at://did:web:hold.example.com/io.atcr.hold.scan/abc123",
|
||||
"cid": "bafyreiabc123",
|
||||
"value": record,
|
||||
}
|
||||
b, _ := json.Marshal(envelope)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func setupVulnDetailsHandler(t *testing.T) *handlers.VulnDetailsHandler {
|
||||
t.Helper()
|
||||
templates, err := appview.Templates(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load templates: %v", err)
|
||||
}
|
||||
return &handlers.VulnDetailsHandler{
|
||||
BaseUIHandler: handlers.BaseUIHandler{
|
||||
Templates: templates,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulnDetails_FullReport(t *testing.T) {
|
||||
grypeJSON := mockGrypeReport()
|
||||
|
||||
// Mock hold that serves both getRecord and getBlob
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if strings.Contains(path, "getRecord") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3)))
|
||||
} else if strings.Contains(path, "getBlob") {
|
||||
// Serve the Grype JSON directly (no redirect in tests)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(grypeJSON))
|
||||
} else {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupVulnDetailsHandler(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Should contain CVE IDs
|
||||
if !strings.Contains(body, "CVE-2024-1234") {
|
||||
t.Error("Expected body to contain CVE-2024-1234")
|
||||
}
|
||||
if !strings.Contains(body, "GHSA-abcd-efgh-ijkl") {
|
||||
t.Error("Expected body to contain GHSA-abcd-efgh-ijkl")
|
||||
}
|
||||
|
||||
// Should contain package names
|
||||
if !strings.Contains(body, "libssl") {
|
||||
t.Error("Expected body to contain package name 'libssl'")
|
||||
}
|
||||
if !strings.Contains(body, "express") {
|
||||
t.Error("Expected body to contain package name 'express'")
|
||||
}
|
||||
|
||||
// Should contain NVD link for CVE
|
||||
if !strings.Contains(body, "nvd.nist.gov") {
|
||||
t.Error("Expected body to contain NVD link")
|
||||
}
|
||||
// Should contain GitHub advisory link for GHSA
|
||||
if !strings.Contains(body, "github.com/advisories") {
|
||||
t.Error("Expected body to contain GitHub advisory link")
|
||||
}
|
||||
|
||||
// Should contain fix version
|
||||
if !strings.Contains(body, "1.2.4") {
|
||||
t.Error("Expected body to contain fix version '1.2.4'")
|
||||
}
|
||||
|
||||
// Should contain "No fix" for unfixed vuln
|
||||
if !strings.Contains(body, "No fix") {
|
||||
t.Error("Expected body to contain 'No fix' for unfixed vulnerability")
|
||||
}
|
||||
|
||||
// Should contain a table
|
||||
if !strings.Contains(body, "<table") {
|
||||
t.Error("Expected body to contain a table element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulnDetails_NoVulnReportBlob(t *testing.T) {
|
||||
// Mock hold returns scan record WITHOUT VulnReportBlob
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(mockScanRecordWithoutBlob(2, 5, 10, 3, 20)))
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupVulnDetailsHandler(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Should show summary counts
|
||||
if !strings.Contains(body, "2 Critical") {
|
||||
t.Error("Expected body to contain '2 Critical' summary")
|
||||
}
|
||||
|
||||
// Should indicate no detailed report
|
||||
if !strings.Contains(body, "No detailed vulnerability report") {
|
||||
t.Error("Expected body to indicate no detailed report available")
|
||||
}
|
||||
|
||||
// Should NOT contain a table
|
||||
if strings.Contains(body, "<table") {
|
||||
t.Error("Should not render a table when no VulnReportBlob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulnDetails_NotFound(t *testing.T) {
|
||||
// Mock hold returns 404
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupVulnDetailsHandler(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Should show an error message (modal still needs content)
|
||||
if !strings.Contains(body, "No scan record found") {
|
||||
t.Error("Expected body to contain error message for 404")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulnDetails_SortsBySeverity(t *testing.T) {
|
||||
grypeJSON := mockGrypeReport() // Has Critical, Low, High in that order
|
||||
|
||||
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if strings.Contains(path, "getRecord") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3)))
|
||||
} else if strings.Contains(path, "getBlob") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(grypeJSON))
|
||||
}
|
||||
}))
|
||||
defer hold.Close()
|
||||
|
||||
handler := setupVulnDetailsHandler(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Critical should appear before High, which should appear before Low
|
||||
critIdx := strings.Index(body, "CVE-2024-1234") // Critical
|
||||
highIdx := strings.Index(body, "GHSA-abcd-efgh") // High
|
||||
lowIdx := strings.Index(body, "CVE-2024-5678") // Low
|
||||
|
||||
if critIdx == -1 || highIdx == -1 || lowIdx == -1 {
|
||||
t.Fatal("Expected all three CVEs to be present in body")
|
||||
}
|
||||
|
||||
if critIdx > highIdx {
|
||||
t.Error("Critical CVE should appear before High CVE")
|
||||
}
|
||||
if highIdx > lowIdx {
|
||||
t.Error("High CVE should appear before Low CVE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulnDetails_MissingParams(t *testing.T) {
|
||||
handler := setupVulnDetailsHandler(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/vuln-details", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
if !strings.Contains(body, "Missing required parameters") {
|
||||
t.Error("Expected error message for missing parameters")
|
||||
}
|
||||
}
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -122,6 +122,10 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
// Manifest health check API endpoint (HTMX polling)
|
||||
router.Get("/api/manifest-health", (&uihandlers.ManifestHealthHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
|
||||
// Vulnerability scan result API endpoints (HTMX lazy loading + modal content)
|
||||
router.Get("/api/scan-result", (&uihandlers.ScanResultHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
|
||||
router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.UserPageHandler{BaseUIHandler: base},
|
||||
).ServeHTTP)
|
||||
|
||||
@@ -363,6 +363,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Vulnerability details modal
|
||||
async function openVulnDetails(digest, holdEndpoint) {
|
||||
const modal = document.getElementById('vuln-detail-modal');
|
||||
const body = document.getElementById('vuln-modal-body');
|
||||
if (!modal || !body) return;
|
||||
|
||||
// Show modal with loading spinner
|
||||
body.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-lg"></span></div>';
|
||||
modal.showModal();
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/vuln-details?digest=${encodeURIComponent(digest)}&holdEndpoint=${encodeURIComponent(holdEndpoint)}`);
|
||||
body.innerHTML = await resp.text();
|
||||
} catch {
|
||||
body.innerHTML = '<p class="text-error">Failed to load vulnerability details</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Login page recent accounts helper (works alongside actor-typeahead web component)
|
||||
class RecentAccountsHelper {
|
||||
constructor(inputElement) {
|
||||
@@ -692,3 +710,4 @@ window.copyToClipboard = copyToClipboard;
|
||||
window.toggleOfflineManifests = toggleOfflineManifests;
|
||||
window.deleteManifest = deleteManifest;
|
||||
window.closeManifestDeleteModal = closeManifestDeleteModal;
|
||||
window.openVulnDetails = openVulnDetails;
|
||||
|
||||
@@ -208,6 +208,13 @@
|
||||
{{ else if not .Reachable }}
|
||||
<span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span>
|
||||
{{ end }}
|
||||
{{/* Vulnerability scan badge (lazy-loaded from hold) */}}
|
||||
{{ if and (not .IsManifestList) .Manifest.HoldEndpoint }}
|
||||
<span hx-get="/api/scan-result?digest={{ .Manifest.Digest | urlquery }}&holdEndpoint={{ .Manifest.HoldEndpoint | urlquery }}"
|
||||
hx-trigger="load delay:1s"
|
||||
hx-swap="outerHTML">
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
|
||||
@@ -283,6 +290,20 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Vulnerability Details Modal -->
|
||||
<dialog id="vuln-detail-modal" class="modal">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<h3 class="text-lg font-bold">Vulnerability Scan Results</h3>
|
||||
<div id="vuln-modal-body" class="py-4">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog"><button class="btn">Close</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{{ define "vuln-badge" }}
|
||||
{{ if .Error }}
|
||||
{{/* Silently hide on error / no scan record — scan badges are non-critical */}}
|
||||
{{ else if eq .Total 0 }}
|
||||
<span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span>
|
||||
{{ else }}
|
||||
<button class="flex items-center gap-1 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onclick="openVulnDetails('{{ .Digest }}', '{{ .HoldEndpoint }}')"
|
||||
title="Click for vulnerability details (scanned {{ .ScannedAt }})">
|
||||
{{ if gt .Critical 0 }}
|
||||
<span class="badge badge-sm badge-error">C:{{ .Critical }}</span>
|
||||
{{ end }}
|
||||
{{ if gt .High 0 }}
|
||||
<span class="badge badge-sm badge-warning">H:{{ .High }}</span>
|
||||
{{ end }}
|
||||
{{ if gt .Medium 0 }}
|
||||
<span class="badge badge-sm badge-soft badge-warning">M:{{ .Medium }}</span>
|
||||
{{ end }}
|
||||
{{ if gt .Low 0 }}
|
||||
<span class="badge badge-sm badge-info">L:{{ .Low }}</span>
|
||||
{{ end }}
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -0,0 +1,87 @@
|
||||
{{ define "vuln-details" }}
|
||||
{{ if .Error }}
|
||||
{{ if .Summary.Total }}
|
||||
<!-- Summary available but no detailed report -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }}
|
||||
{{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }}
|
||||
{{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }}
|
||||
{{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }}
|
||||
</div>
|
||||
<p class="text-base-content/60 text-sm">{{ .Error }}</p>
|
||||
{{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-base-content/60">{{ .Error }}</p>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="space-y-4">
|
||||
<!-- Summary badges -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities found</span>
|
||||
{{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }}
|
||||
{{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }}
|
||||
{{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }}
|
||||
{{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
|
||||
{{ if .Matches }}
|
||||
<!-- CVE table -->
|
||||
<div class="overflow-x-auto max-h-96">
|
||||
<table class="table table-sm table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CVE</th>
|
||||
<th>Severity</th>
|
||||
<th>Package</th>
|
||||
<th>Installed</th>
|
||||
<th>Fixed In</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Matches }}
|
||||
<tr>
|
||||
<td class="font-mono text-xs">
|
||||
{{ if .CVEURL }}
|
||||
<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary">{{ .CVEID }}</a>
|
||||
{{ else }}
|
||||
{{ .CVEID }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
{{ if eq .Severity "Critical" }}
|
||||
<span class="badge badge-sm badge-error">Critical</span>
|
||||
{{ else if eq .Severity "High" }}
|
||||
<span class="badge badge-sm badge-warning">High</span>
|
||||
{{ else if eq .Severity "Medium" }}
|
||||
<span class="badge badge-sm badge-soft badge-warning">Medium</span>
|
||||
{{ else if eq .Severity "Low" }}
|
||||
<span class="badge badge-sm badge-info">Low</span>
|
||||
{{ else }}
|
||||
<span class="badge badge-sm badge-ghost">{{ .Severity }}</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-mono text-xs">{{ .Package }}</span>
|
||||
{{ if .Type }}<span class="text-base-content/40 text-xs">({{ .Type }})</span>{{ end }}
|
||||
</td>
|
||||
<td class="font-mono text-xs">{{ .Version }}</td>
|
||||
<td class="font-mono text-xs">
|
||||
{{ if .FixedIn }}
|
||||
<span class="text-success">{{ .FixedIn }}</span>
|
||||
{{ else }}
|
||||
<span class="text-base-content/40">No fix</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user