diff --git a/api/openapi.yaml b/api/openapi.yaml index b6aa88b6..677c5d23 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5204,6 +5204,14 @@ components: properties: scan_id: {type: string, format: uuid} host_id: {type: string, format: uuid} + # Human-friendly host label resolved from the hosts table for the + # scan-detail header (so it shows a hostname/IP, not a raw UUID). + # Populated on the detail endpoint (GET /scans/{id}); the list + # endpoint leaves them absent since the caller already has host + # context. hostname is the registered hostname (may be empty); + # ip_address is always present for a real host. Spec api-scans. + hostname: {type: string} + ip_address: {type: string} status: {type: string, description: 'queued | running | completed | failed'} trigger_source: {type: string, description: 'on_demand | scheduled'} queued_at: {type: string, format: date-time} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 382c9c70..b07313a8 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -3479,6 +3479,8 @@ export interface components { scan_id: string; /** Format: uuid */ host_id: string; + hostname?: string; + ip_address?: string; /** @description queued | running | completed | failed */ status: string; /** @description on_demand | scheduled */ diff --git a/frontend/src/pages/scans/ScanDetailPage.tsx b/frontend/src/pages/scans/ScanDetailPage.tsx index 0607a9c3..0f8e79b4 100644 --- a/frontend/src/pages/scans/ScanDetailPage.tsx +++ b/frontend/src/pages/scans/ScanDetailPage.tsx @@ -143,7 +143,9 @@ export function ScanDetailPage() { > - {scan.host_id.slice(0, 8)} + {/* Human-friendly label: hostname if registered, else IP, else + a short UUID as a last resort (api-scans resolves these). */} + {scan.hostname || scan.ip_address || scan.host_id.slice(0, 8)} {scan.status} diff --git a/frontend/tests/pages/scan-detail.test.tsx b/frontend/tests/pages/scan-detail.test.tsx index ba1cea4c..052f27b3 100644 --- a/frontend/tests/pages/scan-detail.test.tsx +++ b/frontend/tests/pages/scan-detail.test.tsx @@ -161,4 +161,16 @@ describe('frontend-scan-detail', () => { // No em-dash in copy. expect(stripComments(PAGE_SRC)).not.toContain('—'); }); + + // @ac AC-08 + test('frontend-scan-detail/AC-08 — Host field shows hostname || ip || short uuid, not a bare uuid', () => { + // The Host Meta uses the hostname-then-IP-then-short-UUID fallback. + expect(PAGE_SRC).toMatch( + /scan\.hostname \|\| scan\.ip_address \|\| scan\.host_id\.slice\(0, 8\)/, + ); + // And it is NOT the old bare-UUID render (slice as the sole child). + expect(PAGE_SRC).not.toMatch(/>\s*\{scan\.host_id\.slice\(0, 8\)\}\s* + // + ip 192.0.2.30). + hostID := seedHostForIntel(t, pool) + var wantHostname string + if err := pool.QueryRow(ctx, `SELECT hostname FROM hosts WHERE id=$1`, hostID). + Scan(&wantHostname); err != nil { + t.Fatalf("read seeded hostname: %v", err) + } + scanID := seedScan(t, pool, hostID, time.Now().UTC(), []scanresult.Result{ + {RuleID: "r1", Status: scanresult.StatusPass, Severity: "low"}, + }) + + // Host with an EMPTY hostname but a real IP. + creator := firstSeededUserID(t, pool) + noNameID, _ := uuid.NewV7() + if _, err := pool.Exec(ctx, + `INSERT INTO hosts (id, hostname, ip_address, created_by) VALUES ($1, '', $2::inet, $3)`, + noNameID, "198.51.100.7", creator); err != nil { + t.Fatalf("seed no-hostname host: %v", err) + } + noNameScan := seedScan(t, pool, noNameID, time.Now().UTC(), []scanresult.Result{ + {RuleID: "r1", Status: scanresult.StatusPass, Severity: "low"}, + }) + + get := func(id string) map[string]any { + req := asRole(t, "GET", url+"/api/v1/scans/"+id, auth.RoleViewer, nil) + resp := doReq(t, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var detail struct { + Scan map[string]any `json:"scan"` + } + if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { + t.Fatalf("decode: %v", err) + } + return detail.Scan + } + + named := get(scanID.String()) + if named["hostname"] != wantHostname { + t.Errorf("hostname = %v, want %q", named["hostname"], wantHostname) + } + if named["ip_address"] != "192.0.2.30" { + t.Errorf("ip_address = %v, want 192.0.2.30", named["ip_address"]) + } + + noName := get(noNameScan.String()) + if hn, ok := noName["hostname"]; ok && hn != "" { + t.Errorf("empty-hostname host returned hostname = %v, want empty/absent", hn) + } + if noName["ip_address"] != "198.51.100.7" { + t.Errorf("ip_address = %v, want 198.51.100.7 (IP fallback)", noName["ip_address"]) + } + }) +} + // @ac AC-04 func TestScanDetail_NoRawCheckOutput(t *testing.T) { t.Run("api-scans/AC-04", func(t *testing.T) { diff --git a/internal/server/scans_handlers.go b/internal/server/scans_handlers.go index f2592ea2..2eb59e5a 100644 --- a/internal/server/scans_handlers.go +++ b/internal/server/scans_handlers.go @@ -296,6 +296,16 @@ func toAPIScanSummary(s scanresult.ScanSummary) api.ScanSummary { } out.StartedAt = s.StartedAt out.FinishedAt = s.FinishedAt + // Human-friendly host label (detail endpoint only; empty on list rows, + // which omit them). Pointer-wrap so an unresolved label stays absent. + if s.Hostname != "" { + hn := s.Hostname + out.Hostname = &hn + } + if s.IPAddress != "" { + ip := s.IPAddress + out.IpAddress = &ip + } return out } diff --git a/specs/api/scans.spec.yaml b/specs/api/scans.spec.yaml index 7d5f8781..0e61d0c9 100644 --- a/specs/api/scans.spec.yaml +++ b/specs/api/scans.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-scans title: Scans API — durable scan history, evidence, and OSCAL export - version: "1.0.0" + version: "1.1.0" status: approved tier: 2 @@ -70,6 +70,10 @@ spec: description: The OSCAL endpoints MUST reconstruct a valid OSCAL 1.0.6 Assessment Results document from the stored outcomes via Kensa's exporter (per-rule and whole-scan), return it as a downloadable JSON attachment, and 404 when the scan/rule is unknown type: technical enforcement: error + - id: C-07 + description: 'v1.1.0 — GET /scans/{id} MUST resolve the scan host''s hostname + ip_address from the hosts table and include them on the returned ScanSummary, so the detail header can show a human-friendly host label instead of a raw UUID. A missing host row (unexpected — the FK is ON DELETE RESTRICT) leaves the label fields empty rather than failing the read. The list endpoint (GET /scans) MAY omit these (the caller already has host context)' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -100,3 +104,7 @@ spec: description: Source-inspection — the scan evidence surface lives only in scans_handlers.go; the api-host-compliance pinned handler files (host_compliance_handler.go, host_compliance_lens_handler.go) still contain no occurrence of the token "evidence", so the host tab stays evidence-free while /scans owns evidence. priority: high references_constraints: [C-02] + - id: AC-08 + description: 'v1.1.0 — GET /scans/{id} for a scan whose host has a registered hostname returns ScanSummary.hostname = that hostname and ip_address = the host IP; for a host with an empty hostname, hostname is empty/absent and ip_address is still populated. The values are resolved from the hosts table (not the scan_runs row).' + priority: high + references_constraints: [C-07] diff --git a/specs/frontend/scan-detail.spec.yaml b/specs/frontend/scan-detail.spec.yaml index 510b7e4a..c48ef3fd 100644 --- a/specs/frontend/scan-detail.spec.yaml +++ b/specs/frontend/scan-detail.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-scan-detail title: Scan detail page + per-rule evidence/OSCAL drill-down - version: "1.0.0" + version: "1.1.0" status: approved tier: 2 @@ -80,6 +80,15 @@ spec: no em-dashes and renders no non-functional remediation controls. type: technical enforcement: error + - id: C-08 + description: >- + v1.1.0 — the scan-detail Host field MUST show a human-friendly + label, not a raw UUID: the registered hostname when present, else + the IP address, else a short host_id prefix as a last resort + (scan.hostname || scan.ip_address || scan.host_id.slice(0,8)). It + remains a Link to /hosts/$hostId. + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -118,3 +127,12 @@ spec: Fix or remediation control. priority: high references_constraints: [C-07] + - id: AC-08 + description: >- + v1.1.0 source-inspection — ScanDetailPage's Host Meta renders + `scan.hostname || scan.ip_address || scan.host_id.slice(0, 8)` + (hostname-then-IP-then-short-UUID fallback), wrapped in a Link to + /hosts/$hostId. It does NOT render a bare scan.host_id.slice(0, 8) + as the sole label. + priority: high + references_constraints: [C-08]