diff --git a/docs/implplan/SPRINT_1102_0001_0001_unknowns_scoring_schema.md b/docs/implplan/SPRINT_1102_0001_0001_unknowns_scoring_schema.md index f4227ee32..383f95d59 100644 --- a/docs/implplan/SPRINT_1102_0001_0001_unknowns_scoring_schema.md +++ b/docs/implplan/SPRINT_1102_0001_0001_unknowns_scoring_schema.md @@ -1,6 +1,6 @@ # SPRINT_1102_0001_0001 - Database Schema: Unknowns Scoring & Metrics Tables -**Status:** TODO +**Status:** DONE **Priority:** P0 - CRITICAL **Module:** Signals, Database **Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/` @@ -418,17 +418,17 @@ public sealed class UnknownEntityConfiguration : IEntityTypeConfiguration } }); builder.Services.AddSingleton, ScannerStorageOptionsPostConfigurator>(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddTransient(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -429,6 +435,7 @@ if (app.Environment.IsEnvironment("Testing")) } apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment); +apiGroup.MapProofSpineEndpoints(resolvedOptions.Api.SpinesSegment, resolvedOptions.Api.ScansSegment); apiGroup.MapReplayEndpoints(); if (resolvedOptions.Features.EnablePolicyPreview) diff --git a/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/PostgresProofSpineRepositoryTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/PostgresProofSpineRepositoryTests.cs new file mode 100644 index 000000000..f22e71213 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/PostgresProofSpineRepositoryTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Cryptography; +using StellaOps.Infrastructure.Postgres.Options; +using StellaOps.Scanner.ProofSpine; +using StellaOps.Scanner.ProofSpine.Options; +using StellaOps.Scanner.Storage; +using StellaOps.Scanner.Storage.Postgres; +using StellaOps.Scanner.Storage.Repositories; +using Xunit; + +namespace StellaOps.Scanner.ProofSpine.Tests; + +[Collection("scanner-proofspine-postgres")] +public sealed class PostgresProofSpineRepositoryTests +{ + private readonly ScannerProofSpinePostgresFixture _fixture; + + public PostgresProofSpineRepositoryTests(ScannerProofSpinePostgresFixture fixture) + => _fixture = fixture; + + [Fact] + public async Task SaveAsync_ThenGetByIdAsync_RoundTripsSpine() + { + await _fixture.TruncateAllTablesAsync(); + + var spine = await BuildSampleSpineAsync(scanRunId: "scan-001"); + + var options = new ScannerStorageOptions + { + Postgres = new PostgresOptions + { + ConnectionString = _fixture.ConnectionString, + SchemaName = _fixture.SchemaName + } + }; + + await using var dataSource = new ScannerDataSource(Options.Create(options), NullLogger.Instance); + var repository = new PostgresProofSpineRepository( + dataSource, + NullLogger.Instance, + TimeProvider.System); + + await repository.SaveAsync(spine); + + var fetched = await repository.GetByIdAsync(spine.SpineId); + Assert.NotNull(fetched); + + var segments = await repository.GetSegmentsAsync(spine.SpineId); + Assert.Equal(spine.Segments.Count, segments.Count); + + var summaries = await repository.GetSummariesByScanRunAsync("scan-001"); + Assert.Single(summaries); + Assert.Equal(spine.SpineId, summaries[0].SpineId); + Assert.Equal(spine.Segments.Count, summaries[0].SegmentCount); + } + + private static async Task BuildSampleSpineAsync(string scanRunId) + { + var options = Options.Create(new ProofSpineDsseSigningOptions + { + Mode = "deterministic", + KeyId = "proofspine-test", + AllowDeterministicFallback = true + }); + + var cryptoHash = DefaultCryptoHash.CreateForTests(); + var cryptoHmac = DefaultCryptoHmac.CreateForTests(); + var signer = new HmacDsseSigningService(options, cryptoHmac, cryptoHash); + var profile = new DefaultCryptoProfile(options); + + return await new ProofSpineBuilder(signer, profile, cryptoHash, TimeProvider.System) + .ForArtifact("sha256:feedface") + .ForVulnerability("CVE-2025-0001") + .WithPolicyProfile("default") + .WithScanRun(scanRunId) + .AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") + .AddPolicyEval( + policyDigest: "sha256:policy", + factors: new Dictionary { ["policy"] = "default" }, + verdict: "not_affected", + verdictReason: "component_not_present", + toolId: "policy", + toolVersion: "1.0.0") + .BuildAsync(); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/ProofSpineBuilderTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/ProofSpineBuilderTests.cs new file mode 100644 index 000000000..b25580a0a --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/ProofSpineBuilderTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Options; +using StellaOps.Cryptography; +using StellaOps.Scanner.ProofSpine; +using StellaOps.Scanner.ProofSpine.Options; +using Xunit; + +namespace StellaOps.Scanner.ProofSpine.Tests; + +public sealed class ProofSpineBuilderTests +{ + [Fact] + public async Task BuildAsync_SameInputs_ProducesSameIds() + { + var options = Options.Create(new ProofSpineDsseSigningOptions + { + Mode = "deterministic", + KeyId = "proofspine-test", + AllowDeterministicFallback = true + }); + + var cryptoHash = DefaultCryptoHash.CreateForTests(); + var cryptoHmac = DefaultCryptoHmac.CreateForTests(); + var signer = new HmacDsseSigningService(options, cryptoHmac, cryptoHash); + var profile = new DefaultCryptoProfile(options); + var clock = new FixedTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z")); + + var spine1 = await new ProofSpineBuilder(signer, profile, cryptoHash, clock) + .ForArtifact("sha256:feedface") + .ForVulnerability("CVE-2025-0001") + .WithPolicyProfile("default") + .WithScanRun("scan-001") + .AddSbomSlice("sha256:sbom", new[] { "pkg:b", "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") + .AddPolicyEval( + policyDigest: "sha256:policy", + factors: new Dictionary { ["policy"] = "default" }, + verdict: "not_affected", + verdictReason: "component_not_present", + toolId: "policy", + toolVersion: "1.0.0") + .BuildAsync(); + + var spine2 = await new ProofSpineBuilder(signer, profile, cryptoHash, clock) + .ForArtifact("sha256:feedface") + .ForVulnerability("CVE-2025-0001") + .WithPolicyProfile("default") + .WithScanRun("scan-001") + .AddSbomSlice("sha256:sbom", new[] { "pkg:b", "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") + .AddPolicyEval( + policyDigest: "sha256:policy", + factors: new Dictionary { ["policy"] = "default" }, + verdict: "not_affected", + verdictReason: "component_not_present", + toolId: "policy", + toolVersion: "1.0.0") + .BuildAsync(); + + Assert.Equal(spine1.SpineId, spine2.SpineId); + Assert.Equal(spine1.RootHash, spine2.RootHash); + Assert.Equal(spine1.Segments.Count, spine2.Segments.Count); + Assert.Equal(spine1.Segments[0].SegmentId, spine2.Segments[0].SegmentId); + Assert.Equal(spine1.Segments[1].SegmentId, spine2.Segments[1].SegmentId); + } + + [Fact] + public async Task VerifyAsync_DetectsTampering() + { + var options = Options.Create(new ProofSpineDsseSigningOptions + { + Mode = "deterministic", + KeyId = "proofspine-test", + AllowDeterministicFallback = true + }); + + var cryptoHash = DefaultCryptoHash.CreateForTests(); + var cryptoHmac = DefaultCryptoHmac.CreateForTests(); + var signer = new HmacDsseSigningService(options, cryptoHmac, cryptoHash); + var profile = new DefaultCryptoProfile(options); + var clock = new FixedTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z")); + + var spine = await new ProofSpineBuilder(signer, profile, cryptoHash, clock) + .ForArtifact("sha256:feedface") + .ForVulnerability("CVE-2025-0002") + .WithPolicyProfile("default") + .WithScanRun("scan-002") + .AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") + .AddPolicyEval( + policyDigest: "sha256:policy", + factors: new Dictionary { ["policy"] = "default" }, + verdict: "affected", + verdictReason: "reachable", + toolId: "policy", + toolVersion: "1.0.0") + .BuildAsync(); + + var tampered = spine with + { + Segments = new[] + { + spine.Segments[0] with { ResultHash = spine.Segments[0].ResultHash + "00" }, + spine.Segments[1] + } + }; + + var verifier = new ProofSpineVerifier(signer, cryptoHash); + var verification = await verifier.VerifyAsync(tampered); + + Assert.False(verification.IsValid); + Assert.Contains("root_hash_mismatch", verification.Errors); + Assert.Equal(ProofSegmentStatus.Invalid, verification.Segments[0].Status); + } + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _fixed; + + public FixedTimeProvider(DateTimeOffset fixedInstant) + => _fixed = fixedInstant; + + public override DateTimeOffset GetUtcNow() => _fixed; + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/ScannerProofSpinePostgresFixture.cs b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/ScannerProofSpinePostgresFixture.cs new file mode 100644 index 000000000..b84b94a2d --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/ScannerProofSpinePostgresFixture.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using StellaOps.Infrastructure.Postgres.Testing; +using StellaOps.Scanner.Storage; +using Xunit; + +namespace StellaOps.Scanner.ProofSpine.Tests; + +public sealed class ScannerProofSpinePostgresFixture : PostgresIntegrationFixture, ICollectionFixture +{ + protected override Assembly? GetMigrationAssembly() => typeof(ScannerStorageOptions).Assembly; + + protected override string GetModuleName() => "Scanner.ProofSpine.Tests"; +} + +[CollectionDefinition("scanner-proofspine-postgres")] +public sealed class ScannerProofSpinePostgresCollection : ICollectionFixture +{ +} + diff --git a/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/StellaOps.Scanner.ProofSpine.Tests.csproj b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/StellaOps.Scanner.ProofSpine.Tests.csproj new file mode 100644 index 000000000..638f02b48 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.ProofSpine.Tests/StellaOps.Scanner.ProofSpine.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + preview + enable + enable + false + + + + + + + + + + + + + diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ProofSpineEndpointsTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ProofSpineEndpointsTests.cs new file mode 100644 index 000000000..1a2e91868 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ProofSpineEndpointsTests.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Scanner.ProofSpine; +using Xunit; + +namespace StellaOps.Scanner.WebService.Tests; + +public sealed class ProofSpineEndpointsTests +{ + [Fact] + public async Task GetSpine_ReturnsSpine_WithVerification() + { + await using var factory = new ScannerApplicationFactory(); + using var scope = factory.Services.CreateScope(); + + var builder = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var spine = await builder + .ForArtifact("sha256:feedface") + .ForVulnerability("CVE-2025-0001") + .WithPolicyProfile("default") + .WithScanRun("scan-001") + .AddSbomSlice("sha256:sbom", new[] { "pkg:a", "pkg:b" }, toolId: "sbom", toolVersion: "1.0.0") + .AddPolicyEval( + policyDigest: "sha256:policy", + factors: new Dictionary { ["policy"] = "default" }, + verdict: "not_affected", + verdictReason: "component_not_present", + toolId: "policy", + toolVersion: "1.0.0") + .BuildAsync(); + + await repository.SaveAsync(spine); + + var client = factory.CreateClient(); + var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal(spine.SpineId, body.GetProperty("spineId").GetString()); + + var segments = body.GetProperty("segments"); + Assert.True(segments.GetArrayLength() > 0); + Assert.True(body.TryGetProperty("verification", out _)); + } + + [Fact] + public async Task ListSpinesByScan_ReturnsSummaries_WithSegmentCount() + { + await using var factory = new ScannerApplicationFactory(); + using var scope = factory.Services.CreateScope(); + + var builder = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var spine = await builder + .ForArtifact("sha256:feedface") + .ForVulnerability("CVE-2025-0002") + .WithPolicyProfile("default") + .WithScanRun("scan-002") + .AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") + .AddPolicyEval( + policyDigest: "sha256:policy", + factors: new Dictionary { ["policy"] = "default" }, + verdict: "affected", + verdictReason: "reachable", + toolId: "policy", + toolVersion: "1.0.0") + .BuildAsync(); + + await repository.SaveAsync(spine); + + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/scans/scan-002/spines"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync(); + var items = body.GetProperty("items"); + Assert.Equal(1, items.GetArrayLength()); + Assert.Equal(spine.SpineId, items[0].GetProperty("spineId").GetString()); + Assert.True(items[0].GetProperty("segmentCount").GetInt32() > 0); + } + + [Fact] + public async Task GetSpine_ReturnsInvalidStatus_WhenSegmentTampered() + { + await using var factory = new ScannerApplicationFactory(); + using var scope = factory.Services.CreateScope(); + + var builder = scope.ServiceProvider.GetRequiredService(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var spine = await builder + .ForArtifact("sha256:feedface") + .ForVulnerability("CVE-2025-0003") + .WithPolicyProfile("default") + .WithScanRun("scan-003") + .AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0") + .AddPolicyEval( + policyDigest: "sha256:policy", + factors: new Dictionary { ["policy"] = "default" }, + verdict: "affected", + verdictReason: "reachable", + toolId: "policy", + toolVersion: "1.0.0") + .BuildAsync(); + + var tamperedSegment = spine.Segments[0] with { ResultHash = spine.Segments[0].ResultHash + "00" }; + var tampered = spine with { Segments = new[] { tamperedSegment, spine.Segments[1] } }; + + await repository.SaveAsync(tampered); + + var client = factory.CreateClient(); + var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync(); + var segments = body.GetProperty("segments"); + Assert.Equal("invalid", segments[0].GetProperty("status").GetString()); + } +} diff --git a/src/Web/StellaOps.Web/TASKS.md b/src/Web/StellaOps.Web/TASKS.md index 40a26ab98..9991567e0 100644 --- a/src/Web/StellaOps.Web/TASKS.md +++ b/src/Web/StellaOps.Web/TASKS.md @@ -47,4 +47,4 @@ | WEB-TRIAGE-0215-001 | DONE (2025-12-12) | Added triage TS models + web SDK clients (VEX decisions, audit bundles, vuln-scan attestation predicate) and fixed `scripts/chrome-path.js` so `npm test` runs on Windows Playwright Chromium. | | UI-VEX-0215-A11Y | DONE (2025-12-12) | Added dialog semantics + focus trap for `VexDecisionModalComponent` and Playwright Axe coverage in `tests/e2e/a11y-smoke.spec.ts`. | | UI-TRIAGE-0215-FIXTURES | DONE (2025-12-12) | Made quickstart mock fixtures deterministic for triage surfaces (VEX decisions, audit bundles, vulnerabilities) to support offline-kit hashing and stable tests. | -| UI-TRIAGE-4601-001 | DOING (2025-12-14) | Keyboard shortcuts for triage workspace (SPRINT_4601_0001_0001_keyboard_shortcuts.md). | +| UI-TRIAGE-4601-001 | DONE (2025-12-15) | Keyboard shortcuts for triage workspace (SPRINT_4601_0001_0001_keyboard_shortcuts.md). | diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts index 4039b1fdf..fd013c27d 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts @@ -31,7 +31,12 @@ export interface BundleArtifact { readonly attestation?: BundleArtifactAttestationRef; } -export type BundleVexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED'; +export type BundleVexStatus = + | 'NOT_AFFECTED' + | 'UNDER_INVESTIGATION' + | 'AFFECTED_MITIGATED' + | 'AFFECTED_UNMITIGATED' + | 'FIXED'; export interface BundleVexDecisionEntry { readonly decisionId: string; diff --git a/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts b/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts index c0b0a00b8..c2fb842bc 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts @@ -143,8 +143,13 @@ export interface AocChainEntry { readonly parentHash?: string; } -// VEX Decision types (based on docs/schemas/vex-decision.schema.json) -export type VexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED'; +// VEX Decision types (based on docs/schemas/vex-decision.schema.json) +export type VexStatus = + | 'NOT_AFFECTED' + | 'UNDER_INVESTIGATION' + | 'AFFECTED_MITIGATED' + | 'AFFECTED_UNMITIGATED' + | 'FIXED'; export type VexJustificationType = | 'CODE_NOT_PRESENT' @@ -202,13 +207,14 @@ export interface VexDecision { } // VEX status summary for UI display -export interface VexStatusSummary { - readonly notAffected: number; - readonly affectedMitigated: number; - readonly affectedUnmitigated: number; - readonly fixed: number; - readonly total: number; -} +export interface VexStatusSummary { + readonly notAffected: number; + readonly underInvestigation: number; + readonly affectedMitigated: number; + readonly affectedUnmitigated: number; + readonly fixed: number; + readonly total: number; +} // VEX conflict indicator export interface VexConflict { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html index 110e8d24a..0e22d9bce 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html @@ -535,25 +535,24 @@ aria-label="Previous page" > ← Previous - - - - @for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) { - @let pageNum = currentPage() < 2 ? i : Math.min(currentPage() - 2 + i, totalPages() - 1); - - } - - + } + + - - - +
+ + + +
@if (!selectedVuln()) {
Select a finding to view evidence.
} @else if (activeTab() === 'overview') { -
+

{{ selectedVuln()!.vuln.cveId }}

{{ selectedVuln()!.vuln.title }}

@@ -149,18 +208,94 @@ }
} @else if (activeTab() === 'reachability') { -
-

Reachability

-

- Status: {{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }} - · score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }} -

- +
+
+
+

Reachability

+

+ Status: {{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }} + · score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }} +

+
+ +
+ +
+ + + +
+ + + +
+
+ + @if (reachabilitySearch().length > 0) { +

Search: {{ reachabilitySearch() }}

+ } + +
+ @if (reachabilityView() === 'path-list') { +

Path list view (stub). Use “View call paths” for full evidence.

+ } @else if (reachabilityView() === 'compact-graph') { +

Compact graph view (stub). Rendering of graph nodes is provided by Reachability Center.

+ } @else if (reachabilityView() === 'textual-proof') { +

Textual proof view (stub). Deterministic proof lines will appear here.

+ } +
} @else if (activeTab() === 'policy') { -
+

Policy & gating

Deterministic stub: replace with Policy Engine evaluation data.

@@ -216,7 +351,12 @@ }
} @else if (activeTab() === 'attestations') { -
+

Attestations

@if (attestationsForSelected().length === 0) {

No attestations found for this finding.

@@ -273,6 +413,8 @@ [subject]="{ type: 'IMAGE', name: artifactId(), digest: { sha256: artifactId() } }" [vulnerabilityIds]="vexTargetVulnerabilityIds()" [availableAttestationIds]="availableAttestationIds()" + [existingDecision]="vexExistingDecision()" + [initialStatus]="vexModalInitialStatus()" (closed)="closeVexModal()" (saved)="onVexSaved($event)" /> @@ -284,4 +426,12 @@ (close)="closeAttestationDetail()" /> } + + @if (showKeyboardHelp()) { + + } + + @if (keyboardStatus(); as status) { +
{{ status }}
+ }
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss index 6a50f509b..4b66a41b1 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss @@ -84,6 +84,11 @@ background: #fff; } +.card:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.35); + outline-offset: 2px; +} + .card--selected { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); @@ -196,6 +201,12 @@ cursor: pointer; } +.pill--active { + border-color: #2563eb; + background: rgba(37, 99, 235, 0.1); + color: #1d4ed8; +} + .tabs { display: flex; gap: 0.25rem; @@ -214,6 +225,11 @@ font-size: 0.85rem; } +.tab:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.35); + outline-offset: 2px; +} + .tab--active { border-color: #2563eb; color: #2563eb; @@ -224,6 +240,42 @@ overflow: auto; } +.reachability-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.reachability-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-top: 0.9rem; +} + +.reachability-search { + min-width: 260px; + flex: 1 1 260px; + padding: 0.45rem 0.6rem; + border-radius: 10px; + border: 1px solid #d1d5db; + font-size: 0.9rem; +} + +.reachability-views { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.reachability-view { + margin-top: 0.9rem; + padding-top: 0.9rem; + border-top: 1px solid #f3f4f6; +} + .section + .section { margin-top: 1rem; padding-top: 1rem; @@ -371,3 +423,29 @@ color: #374151; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.kbd-toast { + position: fixed; + right: 1.25rem; + bottom: 1.25rem; + z-index: 230; + padding: 0.5rem 0.75rem; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.95); + color: #f9fafb; + font-size: 0.85rem; + box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35); +} + diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts index 55854d88b..f523057cf 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, flush, flushMicrotasks } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { of } from 'rxjs'; @@ -24,6 +24,8 @@ describe('TriageWorkspaceComponent', () => { title: 'Test', severity: 'high', status: 'open', + reachabilityStatus: 'unknown', + reachabilityScore: 45, affectedComponents: [ { purl: 'pkg:x', name: 'x', version: '1', assetIds: ['asset-web-prod'] }, ], @@ -31,11 +33,23 @@ describe('TriageWorkspaceComponent', () => { { vulnId: 'v-2', cveId: 'CVE-2024-0002', + title: 'Second', + severity: 'high', + status: 'open', + reachabilityStatus: 'reachable', + reachabilityScore: 90, + affectedComponents: [ + { purl: 'pkg:y', name: 'y', version: '1', assetIds: ['asset-web-prod'] }, + ], + }, + { + vulnId: 'v-3', + cveId: 'CVE-2024-0003', title: 'Other asset', severity: 'high', status: 'open', affectedComponents: [ - { purl: 'pkg:y', name: 'y', version: '1', assetIds: ['asset-api-prod'] }, + { purl: 'pkg:z', name: 'z', version: '1', assetIds: ['asset-api-prod'] }, ], }, ]; @@ -55,13 +69,73 @@ describe('TriageWorkspaceComponent', () => { fixture = TestBed.createComponent(TriageWorkspaceComponent); }); + afterEach(() => { + fixture.destroy(); + }); + it('filters findings by artifactId', fakeAsync(() => { fixture.detectChanges(); flushMicrotasks(); const component = fixture.componentInstance; - expect(component.findings().length).toBe(1); - expect(component.findings()[0].vuln.vulnId).toBe('v-1'); + expect(component.findings().length).toBe(2); + expect(component.findings().map((f) => f.vuln.vulnId)).toEqual(['v-1', 'v-2']); + })); + + it('toggles deterministic sort with S', fakeAsync(() => { + fixture.detectChanges(); + flushMicrotasks(); + + const component = fixture.componentInstance; + expect(component.findingsSort()).toBe('default'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true })); + expect(component.findingsSort()).toBe('deterministic'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true })); + expect(component.findingsSort()).toBe('default'); + + flush(); + })); + + it('toggles keyboard help with ?', fakeAsync(() => { + fixture.detectChanges(); + flushMicrotasks(); + + const component = fixture.componentInstance; + expect(component.showKeyboardHelp()).toBeFalse(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true })); + expect(component.showKeyboardHelp()).toBeTrue(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true })); + expect(component.showKeyboardHelp()).toBeFalse(); + + flush(); + })); + + it('selects next finding with ArrowDown', fakeAsync(() => { + fixture.detectChanges(); + flushMicrotasks(); + + const component = fixture.componentInstance; + expect(component.selectedVulnId()).toBe('v-1'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + expect(component.selectedVulnId()).toBe('v-2'); + })); + + it('switches to reachability tab with /', fakeAsync(() => { + fixture.detectChanges(); + flushMicrotasks(); + + const component = fixture.componentInstance; + expect(component.activeTab()).toBe('overview'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true })); + expect(component.activeTab()).toBe('reachability'); + + flush(); })); }); diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts index 01d7b518d..d43b7a110 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts @@ -29,6 +29,13 @@ import { type TabId = 'overview' | 'reachability' | 'policy' | 'attestations'; +const TAB_ORDER: readonly TabId[] = ['overview', 'reachability', 'policy', 'attestations']; +const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [ + 'path-list', + 'compact-graph', + 'textual-proof', +]; + const SEVERITY_ORDER: Record = { critical: 0, high: 1, @@ -73,6 +80,7 @@ interface PolicyGateCell { }) export class TriageWorkspaceComponent implements OnInit, OnDestroy { private readonly document = inject(DOCUMENT); + private readonly host = inject>(ElementRef); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly vulnApi = inject(VULNERABILITY_API); @@ -96,6 +104,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { readonly showVexModal = signal(false); readonly vexTargetVulnerabilityIds = signal([]); readonly vexModalInitialStatus = signal(null); + readonly vexExistingDecision = signal(null); readonly showReachabilityDrawer = signal(false); readonly reachabilityComponent = signal(null); @@ -109,6 +118,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { readonly findingsSort = signal<'default' | 'deterministic'>('default'); readonly keyboardStatus = signal(null); + private keyboardStatusTimeout: number | null = null; + readonly selectedVuln = computed(() => { const id = this.selectedVulnId(); return id ? this.findings().find((f) => f.vuln.vulnId === id) ?? null : null; @@ -261,6 +272,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.shortcuts.destroy(); + this.clearKeyboardStatusTimeout(); } async load(): Promise { @@ -289,9 +301,11 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { } } - selectFinding(vulnId: string): void { + selectFinding(vulnId: string, options?: { resetTab?: boolean }): void { this.selectedVulnId.set(vulnId); - this.activeTab.set('overview'); + if (options?.resetTab ?? true) { + this.activeTab.set('overview'); + } } toggleBulkSelection(vulnId: string): void { @@ -310,14 +324,17 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { openVexForFinding(vulnId: string): void { this.vexModalInitialStatus.set(null); + this.vexExistingDecision.set(null); const selected = this.findings().find((f) => f.vuln.vulnId === vulnId); if (!selected) return; + this.vexExistingDecision.set(this.latestVexDecision(selected.vuln.cveId)); this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]); this.showVexModal.set(true); } openBulkVex(): void { this.vexModalInitialStatus.set(null); + this.vexExistingDecision.set(null); const selectedIds = this.selectedForBulk(); if (selectedIds.length === 0) return; const cves = this.findings() @@ -333,6 +350,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { this.showVexModal.set(false); this.vexTargetVulnerabilityIds.set([]); this.vexModalInitialStatus.set(null); + this.vexExistingDecision.set(null); } onVexSaved(decisions: readonly VexDecision[]): void { @@ -363,6 +381,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { switch (matching.status) { case 'NOT_AFFECTED': return 'VEX: Not affected'; + case 'UNDER_INVESTIGATION': + return 'VEX: Under investigation'; case 'AFFECTED_MITIGATED': return 'VEX: Mitigated'; case 'AFFECTED_UNMITIGATED': @@ -417,6 +437,285 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { this.selectedPolicyCell.set(cell); } + private compareReachability(a: Vulnerability, b: Vulnerability): number { + const order: Record, number> = { + reachable: 0, + unknown: 1, + unreachable: 2, + }; + + const aOrder = a.reachabilityStatus ? order[a.reachabilityStatus] : 3; + const bOrder = b.reachabilityStatus ? order[b.reachabilityStatus] : 3; + if (aOrder !== bOrder) return aOrder - bOrder; + + const aScore = a.reachabilityScore ?? -1; + const bScore = b.reachabilityScore ?? -1; + if (aScore !== bScore) return bScore - aScore; + + return 0; + } + + private compareAge(a: Vulnerability, b: Vulnerability): number { + const aWhen = a.modifiedAt ?? a.publishedAt ?? ''; + const bWhen = b.modifiedAt ?? b.publishedAt ?? ''; + + if (!aWhen && !bWhen) return 0; + if (!aWhen) return 1; + if (!bWhen) return -1; + + return aWhen.localeCompare(bWhen); + } + + private isShortcutOverlayOpen(): boolean { + return this.showVexModal() || this.showKeyboardHelp() || this.showReachabilityDrawer() || this.attestationModal() !== null; + } + + private jumpToIncompleteEvidencePane(): void { + const selected = this.selectedVuln(); + if (!selected) return; + + if (!this.latestVexDecision(selected.vuln.cveId)) { + this.announceKeyboardStatus('Missing VEX decision'); + this.openVexForSelected(); + return; + } + + const reachability = selected.vuln.reachabilityStatus ?? 'unknown'; + if (reachability === 'unknown') { + this.activeTab.set('reachability'); + this.focusTab('reachability'); + this.announceKeyboardStatus('Jumped to reachability evidence'); + return; + } + + if (!this.hasSignedEvidence(selected)) { + this.activeTab.set('attestations'); + this.focusTab('attestations'); + this.announceKeyboardStatus('Jumped to provenance evidence'); + return; + } + + this.announceKeyboardStatus('All evidence complete'); + } + + private focusReachabilitySearch(): void { + this.activeTab.set('reachability'); + this.focusTab('reachability'); + + const view = this.document.defaultView; + if (!view) return; + + view.setTimeout(() => { + try { + this.reachabilitySearchInput?.nativeElement.focus(); + this.reachabilitySearchInput?.nativeElement.select(); + } catch { + // best-effort focus only + } + }, 0); + } + + private cycleReachabilityView(): void { + const current = this.reachabilityView(); + const idx = REACHABILITY_VIEW_ORDER.indexOf(current); + const next = REACHABILITY_VIEW_ORDER[(idx + 1) % REACHABILITY_VIEW_ORDER.length] ?? 'path-list'; + this.reachabilityView.set(next); + this.announceKeyboardStatus(`Reachability view: ${next.replace('-', ' ')}`); + } + + private toggleFindingsSort(): void { + const next = this.findingsSort() === 'deterministic' ? 'default' : 'deterministic'; + this.findingsSort.set(next); + this.announceKeyboardStatus(next === 'deterministic' ? 'Applied deterministic sort' : 'Applied default sort'); + } + + private openQuickVex(status: TriageQuickVexStatus): void { + const selected = this.selectedVuln(); + if (!selected) return; + + this.vexModalInitialStatus.set(this.mapQuickVexStatus(status)); + this.vexExistingDecision.set(null); + this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]); + this.showVexModal.set(true); + } + + private openVexForSelected(): void { + const selected = this.selectedVuln(); + if (!selected) return; + + this.vexModalInitialStatus.set(null); + this.vexExistingDecision.set(this.latestVexDecision(selected.vuln.cveId)); + this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]); + this.showVexModal.set(true); + } + + private selectRelativeFinding(delta: number): void { + const findings = this.findings(); + if (findings.length === 0) return; + + const selectedId = this.selectedVulnId(); + const currentIndex = selectedId ? findings.findIndex((f) => f.vuln.vulnId === selectedId) : -1; + const baseIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = Math.min(findings.length - 1, Math.max(0, baseIndex + delta)); + const nextId = findings[nextIndex]?.vuln.vulnId ?? null; + + if (!nextId || nextId === selectedId) return; + this.selectFinding(nextId, { resetTab: false }); + this.focusFindingCard(nextId); + } + + private selectRelativeTab(delta: number): void { + const current = this.activeTab(); + const idx = TAB_ORDER.indexOf(current); + const next = TAB_ORDER[(idx + delta + TAB_ORDER.length) % TAB_ORDER.length] ?? 'overview'; + this.activeTab.set(next); + this.focusTab(next); + } + + private closeOverlays(): void { + if (this.showKeyboardHelp()) this.showKeyboardHelp.set(false); + if (this.showReachabilityDrawer()) this.closeReachabilityDrawer(); + if (this.attestationModal()) this.attestationModal.set(null); + } + + private toggleKeyboardHelp(): void { + this.showKeyboardHelp.update((v) => !v); + } + + private async copyDsseAttestation(): Promise { + const attestation = this.attestationModal() ?? this.attestationsForSelected()[0] ?? null; + if (!attestation) { + this.announceKeyboardStatus('No attestation available'); + return; + } + + const payload = attestation.raw ? JSON.stringify(attestation.raw, null, 2) : `attestation:${attestation.attestationId}`; + const ok = await this.copyToClipboard(payload); + this.announceKeyboardStatus(ok ? 'Copied attestation to clipboard' : 'Unable to copy to clipboard'); + } + + private mapQuickVexStatus(status: TriageQuickVexStatus): VexStatus { + switch (status) { + case 'NOT_AFFECTED': + return 'NOT_AFFECTED'; + case 'UNDER_INVESTIGATION': + return 'UNDER_INVESTIGATION'; + case 'AFFECTED': + default: + return 'AFFECTED_UNMITIGATED'; + } + } + + private latestVexDecision(vulnerabilityId: string): VexDecision | null { + const artifactId = this.artifactId(); + if (!artifactId) return null; + + const matching = this.vexDecisions() + .filter((d) => d.vulnerabilityId === vulnerabilityId && d.subject.name === artifactId) + .slice() + .sort((a, b) => { + const aWhen = a.updatedAt ?? a.createdAt; + const bWhen = b.updatedAt ?? b.createdAt; + const cmp = bWhen.localeCompare(aWhen); + return cmp !== 0 ? cmp : a.id.localeCompare(b.id); + })[0]; + + return matching ?? null; + } + + private announceKeyboardStatus(message: string, ttlMs = 2000): void { + this.keyboardStatus.set(message); + this.clearKeyboardStatusTimeout(); + + const view = this.document.defaultView; + if (!view) return; + + this.keyboardStatusTimeout = view.setTimeout(() => { + this.keyboardStatusTimeout = null; + this.keyboardStatus.set(null); + }, ttlMs); + } + + private clearKeyboardStatusTimeout(): void { + const view = this.document.defaultView; + if (this.keyboardStatusTimeout === null || !view) return; + view.clearTimeout(this.keyboardStatusTimeout); + this.keyboardStatusTimeout = null; + } + + private focusTab(tab: TabId): void { + const button = this.document.getElementById(`triage-tab-${tab}`); + if (!(button instanceof HTMLElement)) return; + try { + button.focus(); + } catch { + // best-effort focus only + } + } + + private focusFindingCard(vulnId: string): void { + const selector = `[data-finding-card=\"${this.escapeSelectorValue(vulnId)}\"]`; + const element = this.host.nativeElement.querySelector(selector); + if (!element) return; + + try { + element.scrollIntoView({ block: 'nearest' }); + } catch { + // ignore scroll errors + } + + try { + element.focus(); + } catch { + // best-effort focus only + } + } + + private escapeSelectorValue(value: string): string { + const cssEscape = this.document.defaultView?.CSS?.escape; + if (typeof cssEscape === 'function') return cssEscape(value); + return value.replaceAll('\\', '\\\\').replaceAll('\"', '\\\"'); + } + + private async copyToClipboard(text: string): Promise { + const clipboard = this.document.defaultView?.navigator?.clipboard; + if (clipboard && typeof clipboard.writeText === 'function') { + try { + await clipboard.writeText(text); + return true; + } catch { + // fall back + } + } + + return this.fallbackCopyToClipboard(text); + } + + private fallbackCopyToClipboard(text: string): boolean { + const body = this.document.body; + if (!body) return false; + + const textarea = this.document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + body.appendChild(textarea); + textarea.select(); + + let ok = false; + try { + ok = this.document.execCommand('copy'); + } catch { + ok = false; + } finally { + body.removeChild(textarea); + } + + return ok; + } + private buildMockAttestation(vuln: Vulnerability, artifactId: string): TriageAttestationDetail { const verified = vuln.status !== 'open'; const signer = verified diff --git a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts index a283a7b2c..304078986 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts @@ -38,5 +38,11 @@ describe('VexDecisionModalComponent', () => { component.save(); expect(api.createDecision).toHaveBeenCalled(); }); + + it('prefills status from initialStatus when provided', () => { + fixture.componentRef.setInput('initialStatus', 'UNDER_INVESTIGATION'); + fixture.detectChanges(); + expect(component.status()).toBe('UNDER_INVESTIGATION'); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts index a19a6a277..c1cc7c7dc 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts @@ -28,6 +28,7 @@ import type { VexDecisionCreateRequest } from '../../core/api/vex-decisions.mode const STATUS_OPTIONS: readonly { value: VexStatus; label: string }[] = [ { value: 'NOT_AFFECTED', label: 'Not Affected' }, + { value: 'UNDER_INVESTIGATION', label: 'Under Investigation' }, { value: 'AFFECTED_MITIGATED', label: 'Affected (mitigated)' }, { value: 'AFFECTED_UNMITIGATED', label: 'Affected (unmitigated)' }, { value: 'FIXED', label: 'Fixed' }, @@ -85,6 +86,7 @@ export class VexDecisionModalComponent { readonly vulnerabilityIds = input.required(); readonly availableAttestationIds = input([]); readonly existingDecision = input(null); + readonly initialStatus = input(null); readonly closed = output(); readonly saved = output(); @@ -157,19 +159,28 @@ export class VexDecisionModalComponent { }); constructor() { - effect(() => { - const existing = this.existingDecision(); - if (!existing) return; + effect( + () => { + const existing = this.existingDecision(); + if (existing) { + this.status.set(existing.status); + this.justificationType.set(existing.justificationType); + this.justificationText.set(existing.justificationText ?? ''); + this.environmentsText.set(existing.scope?.environments?.join(', ') ?? ''); + this.projectsText.set(existing.scope?.projects?.join(', ') ?? ''); + this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString())); + this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? '')); + this.evidenceRefs.set(existing.evidenceRefs ?? []); + return; + } - this.status.set(existing.status); - this.justificationType.set(existing.justificationType); - this.justificationText.set(existing.justificationText ?? ''); - this.environmentsText.set(existing.scope?.environments?.join(', ') ?? ''); - this.projectsText.set(existing.scope?.projects?.join(', ') ?? ''); - this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString())); - this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? '')); - this.evidenceRefs.set(existing.evidenceRefs ?? []); - }); + const initialStatus = this.initialStatus(); + if (initialStatus) { + this.status.set(initialStatus); + } + }, + { allowSignalWrites: true } + ); } ngAfterViewInit(): void {