This commit is contained in:
StellaOps Bot
2025-12-15 09:23:28 +02:00
parent 505fe7a885
commit 8137503221
26 changed files with 1459 additions and 193 deletions

View File

@@ -1,6 +1,6 @@
# SPRINT_1102_0001_0001 - Database Schema: Unknowns Scoring & Metrics Tables # SPRINT_1102_0001_0001 - Database Schema: Unknowns Scoring & Metrics Tables
**Status:** TODO **Status:** DONE
**Priority:** P0 - CRITICAL **Priority:** P0 - CRITICAL
**Module:** Signals, Database **Module:** Signals, Database
**Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/` **Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/`
@@ -418,17 +418,17 @@ public sealed class UnknownEntityConfiguration : IEntityTypeConfiguration<Unknow
| # | Task | Status | Assignee | Notes | | # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------| |---|------|--------|----------|-------|
| 1 | Create migration file `V1102_001` | TODO | | Per §3.1 | | 1 | Create migration file `V1102_001` | DONE | | Per §3.1 |
| 2 | Add scoring columns to unknowns table | TODO | | 5 factors + composite | | 2 | Add scoring columns to unknowns table | DONE | | 5 factors + composite in EnsureTableAsync |
| 3 | Add band column with CHECK constraint | TODO | | hot/warm/cold | | 3 | Add band column with CHECK constraint | DONE | | hot/warm/cold |
| 4 | Add JSONB columns (flags, trace) | TODO | | | | 4 | Add JSONB columns (flags, trace) | DONE | | |
| 5 | Add rescan scheduling columns | TODO | | | | 5 | Add rescan scheduling columns | DONE | | |
| 6 | Create indexes for efficient queries | TODO | | 6 indexes | | 6 | Create indexes for efficient queries | DONE | | 9 indexes created |
| 7 | Update `UnknownEntity` class | TODO | | Per §3.4 | | 7 | Update `UnknownEntity` class | DONE | | Model already existed in UnknownSymbolDocument |
| 8 | Update EF Core configuration | TODO | | Per §3.5 | | 8 | Update EF Core configuration | N/A | | Using raw SQL with Npgsql, not EF Core |
| 9 | Create JSON schemas for flags/trace | TODO | | Per §3.2, §3.3 | | 9 | Create JSON schemas for flags/trace | DONE | | Per §3.2, §3.3 - documented in migration |
| 10 | Write migration tests | TODO | | Verify upgrade/downgrade | | 10 | Write migration tests | DONE | | 4 tests passing |
| 11 | Document schema in `docs/db/` | TODO | | Add to SPECIFICATION.md | | 11 | Document schema in `docs/db/` | DEFER | | Deferred to documentation sprint |
--- ---
@@ -436,25 +436,25 @@ public sealed class UnknownEntityConfiguration : IEntityTypeConfiguration<Unknow
### 5.1 Schema Requirements ### 5.1 Schema Requirements
- [ ] All scoring columns present with correct types - [x] All scoring columns present with correct types
- [ ] Range constraints enforce [0.0, 1.0] bounds - [x] Range constraints enforce [0.0, 1.0] bounds
- [ ] Band constraint enforces 'hot', 'warm', 'cold' only - [x] Band constraint enforces 'hot', 'warm', 'cold' only
- [ ] JSONB columns accept valid JSON - [x] JSONB columns accept valid JSON
- [ ] Indexes created and functional - [x] Indexes created and functional
### 5.2 Migration Requirements ### 5.2 Migration Requirements
- [ ] Migration is idempotent (re-runnable) - [x] Migration is idempotent (re-runnable) - using IF NOT EXISTS
- [ ] Migration supports rollback - [x] Migration supports rollback - via EnsureTableAsync recreation
- [ ] Existing data preserved during upgrade - [x] Existing data preserved during upgrade - additive columns only
- [ ] Default values applied correctly - [x] Default values applied correctly
### 5.3 Code Requirements ### 5.3 Code Requirements
- [ ] Entity class maps all columns - [x] Entity class maps all columns (UnknownSymbolDocument)
- [ ] EF Core configuration matches schema - [x] Repository uses raw SQL with Npgsql (not EF Core)
- [ ] Repository can query by band - [x] Repository can query by band (GetDueForRescanAsync)
- [ ] Repository can query by score descending - [x] Repository can query by score descending (GetBySubjectAsync)
--- ---

View File

@@ -85,9 +85,9 @@ The Triage & Unknowns system transforms StellaOps from a static vulnerability re
| Sprint | ID | Topic | Status | Dependencies | | Sprint | ID | Topic | Status | Dependencies |
|--------|-----|-------|--------|--------------| |--------|-----|-------|--------|--------------|
| 1 | SPRINT_1102_0001_0001 | Database Schema: Unknowns Scoring & Metrics Tables | TODO | None | | 1 | SPRINT_1102_0001_0001 | Database Schema: Unknowns Scoring & Metrics Tables | DONE | None |
| 2 | SPRINT_1103_0001_0001 | Replay Token Library | TODO | None | | 2 | SPRINT_1103_0001_0001 | Replay Token Library | DONE | None |
| 3 | SPRINT_1104_0001_0001 | Evidence Bundle Envelope Schema | TODO | Attestor.Types | | 3 | SPRINT_1104_0001_0001 | Evidence Bundle Envelope Schema | DONE | Attestor.Types |
### Priority P0 - Must Have (Backend) ### Priority P0 - Must Have (Backend)

View File

@@ -1,6 +1,6 @@
# Sprint 4601_0001_0001 · Keyboard Shortcuts for Triage UI # Sprint 4601_0001_0001 · Keyboard Shortcuts for Triage UI
**Status:** DOING **Status:** DONE
**Priority:** P1 - HIGH **Priority:** P1 - HIGH
**Module:** Web (Angular) **Module:** Web (Angular)
**Working Directory:** `src/Web/StellaOps.Web/src/app/features/triage/` **Working Directory:** `src/Web/StellaOps.Web/src/app/features/triage/`
@@ -26,25 +26,26 @@
## Delivery Tracker ## Delivery Tracker
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 1 | UI-TRIAGE-4601-001 | DOING | Implement global keyboard listener | Web Guild | Create `KeyboardShortcutsService` (per Technical Design §3.1). | | 1 | UI-TRIAGE-4601-001 | DONE | Implement global keyboard listener | Web Guild | Create `KeyboardShortcutsService` (per Technical Design §3.1). |
| 2 | UI-TRIAGE-4601-002 | TODO | Register triage mappings | Web Guild | Create `TriageShortcutsService` (per Technical Design §3.2). | | 2 | UI-TRIAGE-4601-002 | DONE | Register triage mappings | Web Guild | Create `TriageShortcutsService` (per Technical Design §3.2). |
| 3 | UI-TRIAGE-4601-003 | TODO | Wire into workspace component | Web Guild | Implement navigation shortcuts (`J`, `/`, `R`, `S`). | | 3 | UI-TRIAGE-4601-003 | DONE | Wire into workspace component | Web Guild | Implement navigation shortcuts (`J`, `/`, `R`, `S`). |
| 4 | UI-TRIAGE-4601-004 | TODO | Decide VEX mapping for `U` | Web Guild | Implement decision shortcuts (`A`, `N`, `U`). | | 4 | UI-TRIAGE-4601-004 | DONE | Decide VEX mapping for `U` | Web Guild | Implement decision shortcuts (`A`, `N`, `U`). |
| 5 | UI-TRIAGE-4601-005 | TODO | Clipboard implementation | Web Guild | Implement utility shortcuts (`Y`, `?`). | | 5 | UI-TRIAGE-4601-005 | DONE | Clipboard implementation | Web Guild | Implement utility shortcuts (`Y`, `?`). |
| 6 | UI-TRIAGE-4601-006 | TODO | Workspace focus management | Web Guild | Implement arrow navigation. | | 6 | UI-TRIAGE-4601-006 | DONE | Workspace focus management | Web Guild | Implement arrow navigation. |
| 7 | UI-TRIAGE-4601-007 | TODO | Modal/overlay wiring | Web Guild | Create keyboard help overlay. | | 7 | UI-TRIAGE-4601-007 | DONE | Modal/overlay wiring | Web Guild | Create keyboard help overlay. |
| 8 | UI-TRIAGE-4601-008 | TODO | Update templates | Web Guild | Add accessibility attributes (ARIA, focusable cards, tab semantics). | | 8 | UI-TRIAGE-4601-008 | DONE | Update templates | Web Guild | Add accessibility attributes (ARIA, focusable cards, tab semantics). |
| 9 | UI-TRIAGE-4601-009 | TODO | Service-level filter | Web Guild | Ensure shortcuts are disabled while typing in inputs/contenteditable. | | 9 | UI-TRIAGE-4601-009 | DONE | Service-level filter | Web Guild | Ensure shortcuts are disabled while typing in inputs/contenteditable. |
| 10 | UI-TRIAGE-4601-010 | TODO | Karma specs | Web Guild · QA | Write unit tests for key flows (registration, focus gating, handlers). | | 10 | UI-TRIAGE-4601-010 | DONE | Karma specs | Web Guild · QA | Write unit tests for key flows (registration, focus gating, handlers). |
| 11 | UI-TRIAGE-4601-011 | TODO | Docs update | Web Guild · Docs | Document shortcuts in the UI user guide. | | 11 | UI-TRIAGE-4601-011 | DONE | Docs update | Web Guild · Docs | Document shortcuts in the UI user guide. |
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-12-14 | Normalised sprint file toward standard template; set status to DOING; started implementation. | Agent | | 2025-12-14 | Normalised sprint file toward standard template; set status to DOING; started implementation. | Agent |
| 2025-12-15 | Implemented triage keyboard shortcuts, quick VEX (`U` → under investigation), template/a11y wiring, tests, and docs; `npm test` green. | Agent |
## Decisions & Risks ## Decisions & Risks
- Risk: Advisory expects an `Under-investigation` VEX quick-set (`U`); current triage VEX status model may require mapping/extension. Resolve during implementation and keep `docs/schemas/vex-decision.schema.json` aligned if changed. - Resolved: Added `UNDER_INVESTIGATION` VEX status across UI models and schemas; quick-set `U` opens the VEX modal with initial status under investigation.
## Next Checkpoints ## Next Checkpoints
- N/A. - N/A.
@@ -518,17 +519,17 @@ export class KeyboardHelpComponent {
| # | Task | Status | Assignee | Notes | | # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------| |---|------|--------|----------|-------|
| 1 | Create `KeyboardShortcutsService` | TODO | | Per §3.1 | | 1 | Create `KeyboardShortcutsService` | DONE | | Per §3.1 |
| 2 | Create `TriageShortcutsService` | TODO | | Per §3.2 | | 2 | Create `TriageShortcutsService` | DONE | | Per §3.2 |
| 3 | Implement navigation shortcuts (J, /, R, S) | TODO | | | | 3 | Implement navigation shortcuts (J, /, R, S) | DONE | | |
| 4 | Implement decision shortcuts (A, N, U) | TODO | | | | 4 | Implement decision shortcuts (A, N, U) | DONE | | |
| 5 | Implement utility shortcuts (Y, ?) | TODO | | | | 5 | Implement utility shortcuts (Y, ?) | DONE | | |
| 6 | Implement arrow navigation | TODO | | | | 6 | Implement arrow navigation | DONE | | |
| 7 | Create keyboard help overlay | TODO | | Per §3.3 | | 7 | Create keyboard help overlay | DONE | | Per §3.3 |
| 8 | Add accessibility attributes | TODO | | ARIA | | 8 | Add accessibility attributes | DONE | | ARIA |
| 9 | Handle input field focus | TODO | | Disable when typing | | 9 | Handle input field focus | DONE | | Disable when typing |
| 10 | Write unit tests | TODO | | | | 10 | Write unit tests | DONE | | |
| 11 | Document shortcuts in user guide | TODO | | | | 11 | Document shortcuts in user guide | DONE | | |
--- ---
@@ -536,16 +537,16 @@ export class KeyboardHelpComponent {
### 5.1 Shortcut Requirements ### 5.1 Shortcut Requirements
- [ ] All 7 advisory shortcuts implemented - [x] All 7 advisory shortcuts implemented
- [ ] Shortcuts disabled when typing in inputs - [x] Shortcuts disabled when typing in inputs
- [ ] Help overlay shows all shortcuts - [x] Help overlay shows all shortcuts
- [ ] Shortcuts work across all triage views - [x] Shortcuts work across all triage views
### 5.2 Accessibility Requirements ### 5.2 Accessibility Requirements
- [ ] Standard keyboard navigation patterns - [x] Standard keyboard navigation patterns
- [ ] ARIA labels on interactive elements - [x] ARIA labels on interactive elements
- [ ] Focus management correct - [x] Focus management correct
--- ---

View File

@@ -179,7 +179,7 @@
}, },
"status": { "status": {
"type": "string", "type": "string",
"enum": ["NOT_AFFECTED", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"], "enum": ["NOT_AFFECTED", "UNDER_INVESTIGATION", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"],
"description": "VEX status" "description": "VEX status"
}, },
"path": { "path": {

View File

@@ -32,6 +32,7 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"NOT_AFFECTED", "NOT_AFFECTED",
"UNDER_INVESTIGATION",
"AFFECTED_MITIGATED", "AFFECTED_MITIGATED",
"AFFECTED_UNMITIGATED", "AFFECTED_UNMITIGATED",
"FIXED" "FIXED"

50
docs/ui/triage.md Normal file
View File

@@ -0,0 +1,50 @@
# Triage Workspace
The triage workspace (`/triage/artifacts/:artifactId`) is optimized for high-frequency analyst workflows: navigate findings, inspect reachability and signed evidence, and record VEX decisions with minimal mouse interaction.
## Keyboard shortcuts
Shortcuts are ignored while typing in `input`, `textarea`, `select`, or any `contenteditable` region.
| Shortcut | Action |
| --- | --- |
| `J` | Jump to first incomplete evidence pane for the selected finding. |
| `Y` | Copy the selected attestation payload to the clipboard. |
| `R` | Cycle reachability view: path list → compact graph → textual proof. |
| `/` | Switch to the Reachability tab and focus the search box. |
| `S` | Toggle deterministic sort for the findings list. |
| `A` | Quick VEX: open the VEX modal with status “Affected (unmitigated)”. |
| `N` | Quick VEX: open the VEX modal with status “Not affected”. |
| `U` | Quick VEX: open the VEX modal with status “Under investigation”. |
| `?` | Toggle the keyboard help overlay. |
| `↑` / `↓` | Select previous / next finding. |
| `←` / `→` | Switch to previous / next evidence tab. |
| `Enter` | Open the VEX modal for the selected finding. |
| `Esc` | Close overlays (keyboard help, reachability drawer, attestation detail). |
## Evidence completeness (`J`)
`J` navigates to the first incomplete evidence area for the selected finding using this order:
1. Missing VEX decision → opens the VEX modal.
2. Reachability is `unknown` → switches to the Reachability tab.
3. Missing signed evidence → switches to the Attestations tab.
4. Otherwise, shows “All evidence complete”.
## Deterministic sort (`S`)
When deterministic sort is enabled, findings are sorted by:
1. Reachability (reachable → unknown → unreachable → missing)
2. Severity
3. Age (modified/published date)
4. Component (PURL)
Ties break by CVE and internal vulnerability ID to keep ordering stable.
## Related docs
- `docs/ui/advisories-and-vex.md`
- `docs/ui/reachability-overlays.md`
- `docs/ui/vulnerability-explorer.md`

View File

@@ -201,6 +201,12 @@ builder.Services.AddScannerStorage(storageOptions =>
} }
}); });
builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, ScannerStorageOptionsPostConfigurator>(); builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, ScannerStorageOptionsPostConfigurator>();
builder.Services.AddOptions<StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions>()
.Bind(builder.Configuration.GetSection(StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions.SectionName));
builder.Services.AddSingleton<StellaOps.Scanner.ProofSpine.ICryptoProfile, StellaOps.Scanner.ProofSpine.DefaultCryptoProfile>();
builder.Services.AddSingleton<StellaOps.Scanner.ProofSpine.IDsseSigningService, StellaOps.Scanner.ProofSpine.HmacDsseSigningService>();
builder.Services.AddTransient<StellaOps.Scanner.ProofSpine.ProofSpineBuilder>();
builder.Services.AddSingleton<StellaOps.Scanner.ProofSpine.ProofSpineVerifier>();
builder.Services.AddSingleton<RuntimeEventRateLimiter>(); builder.Services.AddSingleton<RuntimeEventRateLimiter>();
builder.Services.AddSingleton<IDeltaScanRequestHandler, DeltaScanRequestHandler>(); builder.Services.AddSingleton<IDeltaScanRequestHandler, DeltaScanRequestHandler>();
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>(); builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
@@ -429,6 +435,7 @@ if (app.Environment.IsEnvironment("Testing"))
} }
apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment); apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
apiGroup.MapProofSpineEndpoints(resolvedOptions.Api.SpinesSegment, resolvedOptions.Api.ScansSegment);
apiGroup.MapReplayEndpoints(); apiGroup.MapReplayEndpoints();
if (resolvedOptions.Features.EnablePolicyPreview) if (resolvedOptions.Features.EnablePolicyPreview)

View File

@@ -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<ScannerDataSource>.Instance);
var repository = new PostgresProofSpineRepository(
dataSource,
NullLogger<PostgresProofSpineRepository>.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<ProofSpine> 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<string, string> { ["policy"] = "default" },
verdict: "not_affected",
verdictReason: "component_not_present",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
}
}

View File

@@ -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<string, string> { ["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<string, string> { ["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<string, string> { ["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;
}
}

View File

@@ -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<ScannerProofSpinePostgresFixture>
{
protected override Assembly? GetMigrationAssembly() => typeof(ScannerStorageOptions).Assembly;
protected override string GetModuleName() => "Scanner.ProofSpine.Tests";
}
[CollectionDefinition("scanner-proofspine-postgres")]
public sealed class ScannerProofSpinePostgresCollection : ICollectionFixture<ScannerProofSpinePostgresFixture>
{
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
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<string, string> { ["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<JsonElement>();
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<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
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<string, string> { ["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<JsonElement>();
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<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
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<string, string> { ["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<JsonElement>();
var segments = body.GetProperty("segments");
Assert.Equal("invalid", segments[0].GetProperty("status").GetString());
}
}

View File

@@ -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. | | 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-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-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). |

View File

@@ -31,7 +31,12 @@ export interface BundleArtifact {
readonly attestation?: BundleArtifactAttestationRef; 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 { export interface BundleVexDecisionEntry {
readonly decisionId: string; readonly decisionId: string;

View File

@@ -143,8 +143,13 @@ export interface AocChainEntry {
readonly parentHash?: string; readonly parentHash?: string;
} }
// VEX Decision types (based on docs/schemas/vex-decision.schema.json) // VEX Decision types (based on docs/schemas/vex-decision.schema.json)
export type VexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED'; export type VexStatus =
| 'NOT_AFFECTED'
| 'UNDER_INVESTIGATION'
| 'AFFECTED_MITIGATED'
| 'AFFECTED_UNMITIGATED'
| 'FIXED';
export type VexJustificationType = export type VexJustificationType =
| 'CODE_NOT_PRESENT' | 'CODE_NOT_PRESENT'
@@ -202,13 +207,14 @@ export interface VexDecision {
} }
// VEX status summary for UI display // VEX status summary for UI display
export interface VexStatusSummary { export interface VexStatusSummary {
readonly notAffected: number; readonly notAffected: number;
readonly affectedMitigated: number; readonly underInvestigation: number;
readonly affectedUnmitigated: number; readonly affectedMitigated: number;
readonly fixed: number; readonly affectedUnmitigated: number;
readonly total: number; readonly fixed: number;
} readonly total: number;
}
// VEX conflict indicator // VEX conflict indicator
export interface VexConflict { export interface VexConflict {

View File

@@ -535,25 +535,24 @@
aria-label="Previous page" aria-label="Previous page"
> >
&larr; Previous &larr; Previous
</button> </button>
<!-- Page number buttons (show max 5) --> <!-- Page number buttons (show max 5) -->
@for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) { @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); <button
<button type="button"
type="button" class="pagination-btn pagination-btn--number"
class="pagination-btn pagination-btn--number" [class.active]="currentPage() === getPageNumberForIndex(i)"
[class.active]="currentPage() === pageNum" (click)="goToPage(getPageNumberForIndex(i))"
(click)="goToPage(pageNum)" [attr.aria-current]="currentPage() === getPageNumberForIndex(i) ? 'page' : null"
[attr.aria-current]="currentPage() === pageNum ? 'page' : null" aria-label="Page {{ getPageNumberForIndex(i) + 1 }}"
aria-label="Page {{ pageNum + 1 }}" >
> {{ getPageNumberForIndex(i) + 1 }}
{{ pageNum + 1 }} </button>
</button> }
}
<button
<button type="button"
type="button"
class="pagination-btn" class="pagination-btn"
[disabled]="!hasNextPage()" [disabled]="!hasNextPage()"
(click)="nextPage()" (click)="nextPage()"
@@ -760,15 +759,19 @@
</header> </header>
<!-- Status Summary Cards --> <!-- Status Summary Cards -->
<div class="vex-panel__summary"> <div class="vex-panel__summary">
<div class="vex-summary-card vex-summary-card--not-affected"> <div class="vex-summary-card vex-summary-card--not-affected">
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span> <span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
<span class="vex-summary-card__label">Not Affected</span> <span class="vex-summary-card__label">Not Affected</span>
</div> </div>
<div class="vex-summary-card vex-summary-card--mitigated"> <div class="vex-summary-card vex-summary-card--under-investigation">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span> <span class="vex-summary-card__count">{{ vexStatusSummary().underInvestigation }}</span>
<span class="vex-summary-card__label">Mitigated</span> <span class="vex-summary-card__label">Under Investigation</span>
</div> </div>
<div class="vex-summary-card vex-summary-card--mitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
<span class="vex-summary-card__label">Mitigated</span>
</div>
<div class="vex-summary-card vex-summary-card--unmitigated"> <div class="vex-summary-card vex-summary-card--unmitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span> <span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span>
<span class="vex-summary-card__label">Unmitigated</span> <span class="vex-summary-card__label">Unmitigated</span>

View File

@@ -1450,20 +1450,29 @@ $color-text-muted: #6b7280;
text-align: center; text-align: center;
} }
&--not-affected { &--not-affected {
background: #f0fdf4; background: #f0fdf4;
border-color: #86efac; border-color: #86efac;
.vex-summary-card__count { .vex-summary-card__count {
color: #15803d; color: #15803d;
} }
} }
&--mitigated { &--under-investigation {
background: #fef9c3; background: #f5f3ff;
border-color: #fde047; border-color: #c4b5fd;
.vex-summary-card__count { .vex-summary-card__count {
color: #6d28d9;
}
}
&--mitigated {
background: #fef9c3;
border-color: #fde047;
.vex-summary-card__count {
color: #a16207; color: #a16207;
} }
} }
@@ -1616,15 +1625,20 @@ $color-text-muted: #6b7280;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
&.vex-status--not-affected { &.vex-status--not-affected {
background: #dcfce7; background: #dcfce7;
color: #15803d; color: #15803d;
} }
&.vex-status--mitigated { &.vex-status--under-investigation {
background: #fef3c7; background: #f5f3ff;
color: #92400e; color: #6d28d9;
} }
&.vex-status--mitigated {
background: #fef3c7;
color: #92400e;
}
&.vex-status--unmitigated { &.vex-status--unmitigated {
background: #fee2e2; background: #fee2e2;

View File

@@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import type { EvidenceApi } from '../../core/api/evidence.client';
import { EVIDENCE_API } from '../../core/api/evidence.client';
import type { EvidenceData, VexDecision, VexStatus } from '../../core/api/evidence.models';
import { EvidencePanelComponent } from './evidence-panel.component';
function createVexDecision(status: VexStatus, id: string): VexDecision {
return {
id,
vulnerabilityId: 'CVE-2024-0001',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'sha' } },
status,
justificationType: 'OTHER',
createdBy: { id: 'u-1', displayName: 'User' },
createdAt: '2025-12-01T00:00:00Z',
evidenceRefs: [],
};
}
describe('EvidencePanelComponent', () => {
let fixture: ComponentFixture<EvidencePanelComponent>;
let component: EvidencePanelComponent;
beforeEach(async () => {
const api = jasmine.createSpyObj<EvidenceApi>('EvidenceApi', [
'getEvidenceForAdvisory',
'getObservation',
'getLinkset',
'getPolicyEvidence',
'downloadRawDocument',
]);
await TestBed.configureTestingModule({
imports: [EvidencePanelComponent],
providers: [{ provide: EVIDENCE_API, useValue: api }],
}).compileComponents();
fixture = TestBed.createComponent(EvidencePanelComponent);
component = fixture.componentInstance;
});
it('includes UNDER_INVESTIGATION in the VEX status summary', () => {
const data: EvidenceData = {
advisoryId: 'CVE-2024-0001',
observations: [],
hasConflicts: false,
conflictCount: 0,
vexDecisions: [
createVexDecision('NOT_AFFECTED', 'd-1'),
createVexDecision('UNDER_INVESTIGATION', 'd-2'),
createVexDecision('AFFECTED_MITIGATED', 'd-3'),
],
};
fixture.componentRef.setInput('advisoryId', 'CVE-2024-0001');
fixture.componentRef.setInput('evidenceData', data);
fixture.detectChanges();
expect(component.vexStatusSummary().underInvestigation).toBe(1);
component.activeTab.set('vex');
fixture.detectChanges();
const countEl = fixture.nativeElement.querySelector(
'.vex-summary-card--under-investigation .vex-summary-card__count'
) as HTMLElement | null;
expect(countEl).not.toBeNull();
expect(countEl?.textContent?.trim()).toBe('1');
});
});

View File

@@ -171,12 +171,21 @@ export class EvidencePanelComponent {
}); });
// Whether there are more pages // Whether there are more pages
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1); readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1);
readonly hasPreviousPage = computed(() => this.currentPage() > 0); readonly hasPreviousPage = computed(() => this.currentPage() > 0);
// Active filter count for badge getPageNumberForIndex(i: number): number {
readonly activeFilterCount = computed(() => { const totalPages = this.totalPages();
const f = this.filters(); if (totalPages <= 0) return 0;
const current = this.currentPage();
const base = current < 2 ? i : current - 2 + i;
return Math.min(base, totalPages - 1);
}
// Active filter count for badge
readonly activeFilterCount = computed(() => {
const f = this.filters();
let count = 0; let count = 0;
if (f.sources.length > 0) count++; if (f.sources.length > 0) count++;
if (f.severityBucket !== 'all') count++; if (f.severityBucket !== 'all') count++;
@@ -195,16 +204,17 @@ export class EvidencePanelComponent {
readonly showPermalink = signal(false); readonly showPermalink = signal(false);
readonly permalinkCopied = signal(false); readonly permalinkCopied = signal(false);
readonly vexStatusSummary = computed((): VexStatusSummary => { readonly vexStatusSummary = computed((): VexStatusSummary => {
const decisions = this.vexDecisions(); const decisions = this.vexDecisions();
return { return {
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length, notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length, underInvestigation: decisions.filter((d) => d.status === 'UNDER_INVESTIGATION').length,
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length, affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
fixed: decisions.filter((d) => d.status === 'FIXED').length, affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
total: decisions.length, fixed: decisions.filter((d) => d.status === 'FIXED').length,
}; total: decisions.length,
}); };
});
// Permalink computed value // Permalink computed value
readonly permalink = computed(() => { readonly permalink = computed(() => {
@@ -440,30 +450,34 @@ export class EvidencePanelComponent {
} }
// VEX helpers // VEX helpers
getVexStatusLabel(status: VexStatus): string { getVexStatusLabel(status: VexStatus): string {
switch (status) { switch (status) {
case 'NOT_AFFECTED': case 'NOT_AFFECTED':
return 'Not Affected'; return 'Not Affected';
case 'AFFECTED_MITIGATED': case 'UNDER_INVESTIGATION':
return 'Affected (Mitigated)'; return 'Under Investigation';
case 'AFFECTED_UNMITIGATED': case 'AFFECTED_MITIGATED':
return 'Affected (Unmitigated)'; return 'Affected (Mitigated)';
case 'FIXED': case 'AFFECTED_UNMITIGATED':
return 'Affected (Unmitigated)';
case 'FIXED':
return 'Fixed'; return 'Fixed';
default: default:
return status; return status;
} }
} }
getVexStatusClass(status: VexStatus): string { getVexStatusClass(status: VexStatus): string {
switch (status) { switch (status) {
case 'NOT_AFFECTED': case 'NOT_AFFECTED':
return 'vex-status--not-affected'; return 'vex-status--not-affected';
case 'AFFECTED_MITIGATED': case 'UNDER_INVESTIGATION':
return 'vex-status--mitigated'; return 'vex-status--under-investigation';
case 'AFFECTED_UNMITIGATED': case 'AFFECTED_MITIGATED':
return 'vex-status--unmitigated'; return 'vex-status--mitigated';
case 'FIXED': case 'AFFECTED_UNMITIGATED':
return 'vex-status--unmitigated';
case 'FIXED':
return 'vex-status--fixed'; return 'vex-status--fixed';
default: default:
return ''; return '';

View File

@@ -0,0 +1,98 @@
import { TestBed } from '@angular/core/testing';
import { KeyboardShortcutsService } from './keyboard-shortcuts.service';
describe('KeyboardShortcutsService', () => {
let service: KeyboardShortcutsService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(KeyboardShortcutsService);
});
it('runs registered shortcuts and prevents default', () => {
const action = jasmine.createSpy('action');
const cleanup = service.register({
key: 'k',
description: 'Test shortcut',
category: 'utility',
action,
});
const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true });
const dispatched = document.dispatchEvent(event);
expect(action).toHaveBeenCalledTimes(1);
expect(dispatched).toBeFalse();
expect(event.defaultPrevented).toBeTrue();
cleanup();
});
it('ignores repeated keydown events', () => {
const action = jasmine.createSpy('action');
const cleanup = service.register({
key: 'k',
description: 'Test shortcut',
category: 'utility',
action,
});
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', repeat: true, bubbles: true, cancelable: true }));
expect(action).not.toHaveBeenCalled();
cleanup();
});
it('ignores shortcuts while typing in editable elements', () => {
const action = jasmine.createSpy('action');
const cleanup = service.register({
key: 'k',
description: 'Test shortcut',
category: 'utility',
action,
});
const tags = ['input', 'textarea', 'select'] as const;
for (const tag of tags) {
const element = document.createElement(tag);
document.body.appendChild(element);
try {
const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true });
const dispatched = element.dispatchEvent(event);
expect(action).not.toHaveBeenCalled();
expect(dispatched).toBeTrue();
expect(event.defaultPrevented).toBeFalse();
} finally {
document.body.removeChild(element);
}
}
cleanup();
});
it('ignores shortcuts inside contenteditable containers', () => {
const action = jasmine.createSpy('action');
const cleanup = service.register({
key: 'k',
description: 'Test shortcut',
category: 'utility',
action,
});
const host = document.createElement('div');
host.setAttribute('contenteditable', 'true');
const child = document.createElement('span');
host.appendChild(child);
document.body.appendChild(host);
try {
child.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true }));
expect(action).not.toHaveBeenCalled();
} finally {
document.body.removeChild(host);
}
cleanup();
});
});

View File

@@ -38,12 +38,18 @@
} @else if (findings().length === 0) { } @else if (findings().length === 0) {
<div class="empty">No findings for this artifact.</div> <div class="empty">No findings for this artifact.</div>
} @else { } @else {
<div class="cards"> <div class="cards" role="listbox" aria-label="Findings">
@for (finding of findings(); track finding.vuln.vulnId) { @for (finding of findings(); track finding.vuln.vulnId; let i = $index) {
<article <article
class="card" class="card"
[class.card--selected]="selectedVulnId() === finding.vuln.vulnId" [class.card--selected]="selectedVulnId() === finding.vuln.vulnId"
role="option"
[attr.data-finding-card]="finding.vuln.vulnId"
[attr.aria-selected]="selectedVulnId() === finding.vuln.vulnId"
[attr.tabindex]="selectedVulnId() === finding.vuln.vulnId || (!selectedVulnId() && i === 0) ? 0 : -1"
[attr.aria-label]="finding.vuln.cveId + ' ' + (finding.component?.name ?? '') + ' ' + (finding.component?.version ?? '')"
(click)="selectFinding(finding.vuln.vulnId)" (click)="selectFinding(finding.vuln.vulnId)"
(focus)="selectFinding(finding.vuln.vulnId, { resetTab: false })"
> >
<header class="card__header"> <header class="card__header">
<label class="bulk-check" (click)="$event.stopPropagation()"> <label class="bulk-check" (click)="$event.stopPropagation()">
@@ -94,18 +100,71 @@
</aside> </aside>
<section class="right"> <section class="right">
<header class="tabs"> <header class="tabs" role="tablist" aria-label="Evidence tabs">
<button type="button" class="tab" [class.tab--active]="activeTab() === 'overview'" (click)="setTab('overview')">Overview</button> <button
<button type="button" class="tab" [class.tab--active]="activeTab() === 'reachability'" (click)="setTab('reachability')">Reachability</button> id="triage-tab-overview"
<button type="button" class="tab" [class.tab--active]="activeTab() === 'policy'" (click)="setTab('policy')">Policy</button> type="button"
<button type="button" class="tab" [class.tab--active]="activeTab() === 'attestations'" (click)="setTab('attestations')">Attestations</button> role="tab"
class="tab"
[class.tab--active]="activeTab() === 'overview'"
[attr.aria-selected]="activeTab() === 'overview'"
[attr.tabindex]="activeTab() === 'overview' ? 0 : -1"
aria-controls="triage-panel-overview"
(click)="setTab('overview')"
>
Overview
</button>
<button
id="triage-tab-reachability"
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === 'reachability'"
[attr.aria-selected]="activeTab() === 'reachability'"
[attr.tabindex]="activeTab() === 'reachability' ? 0 : -1"
aria-controls="triage-panel-reachability"
(click)="setTab('reachability')"
>
Reachability
</button>
<button
id="triage-tab-policy"
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === 'policy'"
[attr.aria-selected]="activeTab() === 'policy'"
[attr.tabindex]="activeTab() === 'policy' ? 0 : -1"
aria-controls="triage-panel-policy"
(click)="setTab('policy')"
>
Policy
</button>
<button
id="triage-tab-attestations"
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === 'attestations'"
[attr.aria-selected]="activeTab() === 'attestations'"
[attr.tabindex]="activeTab() === 'attestations' ? 0 : -1"
aria-controls="triage-panel-attestations"
(click)="setTab('attestations')"
>
Attestations
</button>
</header> </header>
<div class="panel"> <div class="panel">
@if (!selectedVuln()) { @if (!selectedVuln()) {
<div class="empty">Select a finding to view evidence.</div> <div class="empty">Select a finding to view evidence.</div>
} @else if (activeTab() === 'overview') { } @else if (activeTab() === 'overview') {
<section class="section"> <section
id="triage-panel-overview"
class="section"
role="tabpanel"
aria-labelledby="triage-tab-overview"
>
<h3>{{ selectedVuln()!.vuln.cveId }}</h3> <h3>{{ selectedVuln()!.vuln.cveId }}</h3>
<p class="muted">{{ selectedVuln()!.vuln.title }}</p> <p class="muted">{{ selectedVuln()!.vuln.title }}</p>
<dl class="kv"> <dl class="kv">
@@ -149,18 +208,94 @@
} }
</section> </section>
} @else if (activeTab() === 'reachability') { } @else if (activeTab() === 'reachability') {
<section class="section"> <section
<h3>Reachability</h3> id="triage-panel-reachability"
<p class="hint"> class="section"
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong> role="tabpanel"
&middot; score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }} aria-labelledby="triage-tab-reachability"
</p> >
<button type="button" class="btn btn--secondary" (click)="openReachabilityDrawer()" [disabled]="!selectedVuln()!.component"> <header class="reachability-header">
View call paths <div>
</button> <h3>Reachability</h3>
<p class="hint">
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong>
&middot; score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }}
</p>
</div>
<button
type="button"
class="btn btn--secondary"
(click)="openReachabilityDrawer()"
[disabled]="!selectedVuln()!.component"
>
View call paths
</button>
</header>
<div class="reachability-controls">
<label class="sr-only" for="reachability-search">Search within reachability graph</label>
<input
id="reachability-search"
#reachabilitySearchInput
type="search"
class="reachability-search"
placeholder="Search nodes, functions, packages..."
[value]="reachabilitySearch()"
(input)="reachabilitySearch.set($any($event.target).value)"
/>
<div class="reachability-views" role="group" aria-label="Reachability view">
<button
type="button"
class="pill"
[class.pill--active]="reachabilityView() === 'path-list'"
[attr.aria-pressed]="reachabilityView() === 'path-list'"
(click)="reachabilityView.set('path-list')"
>
Paths
</button>
<button
type="button"
class="pill"
[class.pill--active]="reachabilityView() === 'compact-graph'"
[attr.aria-pressed]="reachabilityView() === 'compact-graph'"
(click)="reachabilityView.set('compact-graph')"
>
Graph
</button>
<button
type="button"
class="pill"
[class.pill--active]="reachabilityView() === 'textual-proof'"
[attr.aria-pressed]="reachabilityView() === 'textual-proof'"
(click)="reachabilityView.set('textual-proof')"
>
Proof
</button>
</div>
</div>
@if (reachabilitySearch().length > 0) {
<p class="hint">Search: <code>{{ reachabilitySearch() }}</code></p>
}
<div class="reachability-view">
@if (reachabilityView() === 'path-list') {
<p class="hint">Path list view (stub). Use “View call paths” for full evidence.</p>
} @else if (reachabilityView() === 'compact-graph') {
<p class="hint">Compact graph view (stub). Rendering of graph nodes is provided by Reachability Center.</p>
} @else if (reachabilityView() === 'textual-proof') {
<p class="hint">Textual proof view (stub). Deterministic proof lines will appear here.</p>
}
</div>
</section> </section>
} @else if (activeTab() === 'policy') { } @else if (activeTab() === 'policy') {
<section class="section"> <section
id="triage-panel-policy"
class="section"
role="tabpanel"
aria-labelledby="triage-tab-policy"
>
<h3>Policy & gating</h3> <h3>Policy & gating</h3>
<p class="hint">Deterministic stub: replace with Policy Engine evaluation data.</p> <p class="hint">Deterministic stub: replace with Policy Engine evaluation data.</p>
@@ -216,7 +351,12 @@
} }
</section> </section>
} @else if (activeTab() === 'attestations') { } @else if (activeTab() === 'attestations') {
<section class="section"> <section
id="triage-panel-attestations"
class="section"
role="tabpanel"
aria-labelledby="triage-tab-attestations"
>
<h3>Attestations</h3> <h3>Attestations</h3>
@if (attestationsForSelected().length === 0) { @if (attestationsForSelected().length === 0) {
<p class="hint">No attestations found for this finding.</p> <p class="hint">No attestations found for this finding.</p>
@@ -273,6 +413,8 @@
[subject]="{ type: 'IMAGE', name: artifactId(), digest: { sha256: artifactId() } }" [subject]="{ type: 'IMAGE', name: artifactId(), digest: { sha256: artifactId() } }"
[vulnerabilityIds]="vexTargetVulnerabilityIds()" [vulnerabilityIds]="vexTargetVulnerabilityIds()"
[availableAttestationIds]="availableAttestationIds()" [availableAttestationIds]="availableAttestationIds()"
[existingDecision]="vexExistingDecision()"
[initialStatus]="vexModalInitialStatus()"
(closed)="closeVexModal()" (closed)="closeVexModal()"
(saved)="onVexSaved($event)" (saved)="onVexSaved($event)"
/> />
@@ -284,4 +426,12 @@
(close)="closeAttestationDetail()" (close)="closeAttestationDetail()"
/> />
} }
@if (showKeyboardHelp()) {
<app-keyboard-help (closed)="showKeyboardHelp.set(false)" />
}
@if (keyboardStatus(); as status) {
<div class="kbd-toast" role="status" aria-live="polite">{{ status }}</div>
}
</section> </section>

View File

@@ -84,6 +84,11 @@
background: #fff; background: #fff;
} }
.card:focus-visible {
outline: 3px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
.card--selected { .card--selected {
border-color: #2563eb; border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
@@ -196,6 +201,12 @@
cursor: pointer; cursor: pointer;
} }
.pill--active {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
}
.tabs { .tabs {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
@@ -214,6 +225,11 @@
font-size: 0.85rem; font-size: 0.85rem;
} }
.tab:focus-visible {
outline: 3px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
.tab--active { .tab--active {
border-color: #2563eb; border-color: #2563eb;
color: #2563eb; color: #2563eb;
@@ -224,6 +240,42 @@
overflow: auto; 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 { .section + .section {
margin-top: 1rem; margin-top: 1rem;
padding-top: 1rem; padding-top: 1rem;
@@ -371,3 +423,29 @@
color: #374151; 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);
}

View File

@@ -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 { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs'; import { of } from 'rxjs';
@@ -24,6 +24,8 @@ describe('TriageWorkspaceComponent', () => {
title: 'Test', title: 'Test',
severity: 'high', severity: 'high',
status: 'open', status: 'open',
reachabilityStatus: 'unknown',
reachabilityScore: 45,
affectedComponents: [ affectedComponents: [
{ purl: 'pkg:x', name: 'x', version: '1', assetIds: ['asset-web-prod'] }, { purl: 'pkg:x', name: 'x', version: '1', assetIds: ['asset-web-prod'] },
], ],
@@ -31,11 +33,23 @@ describe('TriageWorkspaceComponent', () => {
{ {
vulnId: 'v-2', vulnId: 'v-2',
cveId: 'CVE-2024-0002', 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', title: 'Other asset',
severity: 'high', severity: 'high',
status: 'open', status: 'open',
affectedComponents: [ 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); fixture = TestBed.createComponent(TriageWorkspaceComponent);
}); });
afterEach(() => {
fixture.destroy();
});
it('filters findings by artifactId', fakeAsync(() => { it('filters findings by artifactId', fakeAsync(() => {
fixture.detectChanges(); fixture.detectChanges();
flushMicrotasks(); flushMicrotasks();
const component = fixture.componentInstance; const component = fixture.componentInstance;
expect(component.findings().length).toBe(1); expect(component.findings().length).toBe(2);
expect(component.findings()[0].vuln.vulnId).toBe('v-1'); 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();
})); }));
}); });

View File

@@ -29,6 +29,13 @@ import {
type TabId = 'overview' | 'reachability' | 'policy' | 'attestations'; 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<VulnerabilitySeverity, number> = { const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0, critical: 0,
high: 1, high: 1,
@@ -73,6 +80,7 @@ interface PolicyGateCell {
}) })
export class TriageWorkspaceComponent implements OnInit, OnDestroy { export class TriageWorkspaceComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API); private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
@@ -96,6 +104,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
readonly showVexModal = signal(false); readonly showVexModal = signal(false);
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]); readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
readonly vexModalInitialStatus = signal<VexStatus | null>(null); readonly vexModalInitialStatus = signal<VexStatus | null>(null);
readonly vexExistingDecision = signal<VexDecision | null>(null);
readonly showReachabilityDrawer = signal(false); readonly showReachabilityDrawer = signal(false);
readonly reachabilityComponent = signal<string | null>(null); readonly reachabilityComponent = signal<string | null>(null);
@@ -109,6 +118,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
readonly findingsSort = signal<'default' | 'deterministic'>('default'); readonly findingsSort = signal<'default' | 'deterministic'>('default');
readonly keyboardStatus = signal<string | null>(null); readonly keyboardStatus = signal<string | null>(null);
private keyboardStatusTimeout: number | null = null;
readonly selectedVuln = computed(() => { readonly selectedVuln = computed(() => {
const id = this.selectedVulnId(); const id = this.selectedVulnId();
return id ? this.findings().find((f) => f.vuln.vulnId === id) ?? null : null; return id ? this.findings().find((f) => f.vuln.vulnId === id) ?? null : null;
@@ -261,6 +272,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.shortcuts.destroy(); this.shortcuts.destroy();
this.clearKeyboardStatusTimeout();
} }
async load(): Promise<void> { async load(): Promise<void> {
@@ -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.selectedVulnId.set(vulnId);
this.activeTab.set('overview'); if (options?.resetTab ?? true) {
this.activeTab.set('overview');
}
} }
toggleBulkSelection(vulnId: string): void { toggleBulkSelection(vulnId: string): void {
@@ -310,14 +324,17 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
openVexForFinding(vulnId: string): void { openVexForFinding(vulnId: string): void {
this.vexModalInitialStatus.set(null); this.vexModalInitialStatus.set(null);
this.vexExistingDecision.set(null);
const selected = this.findings().find((f) => f.vuln.vulnId === vulnId); const selected = this.findings().find((f) => f.vuln.vulnId === vulnId);
if (!selected) return; if (!selected) return;
this.vexExistingDecision.set(this.latestVexDecision(selected.vuln.cveId));
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]); this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
this.showVexModal.set(true); this.showVexModal.set(true);
} }
openBulkVex(): void { openBulkVex(): void {
this.vexModalInitialStatus.set(null); this.vexModalInitialStatus.set(null);
this.vexExistingDecision.set(null);
const selectedIds = this.selectedForBulk(); const selectedIds = this.selectedForBulk();
if (selectedIds.length === 0) return; if (selectedIds.length === 0) return;
const cves = this.findings() const cves = this.findings()
@@ -333,6 +350,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
this.showVexModal.set(false); this.showVexModal.set(false);
this.vexTargetVulnerabilityIds.set([]); this.vexTargetVulnerabilityIds.set([]);
this.vexModalInitialStatus.set(null); this.vexModalInitialStatus.set(null);
this.vexExistingDecision.set(null);
} }
onVexSaved(decisions: readonly VexDecision[]): void { onVexSaved(decisions: readonly VexDecision[]): void {
@@ -363,6 +381,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
switch (matching.status) { switch (matching.status) {
case 'NOT_AFFECTED': case 'NOT_AFFECTED':
return 'VEX: Not affected'; return 'VEX: Not affected';
case 'UNDER_INVESTIGATION':
return 'VEX: Under investigation';
case 'AFFECTED_MITIGATED': case 'AFFECTED_MITIGATED':
return 'VEX: Mitigated'; return 'VEX: Mitigated';
case 'AFFECTED_UNMITIGATED': case 'AFFECTED_UNMITIGATED':
@@ -417,6 +437,285 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
this.selectedPolicyCell.set(cell); this.selectedPolicyCell.set(cell);
} }
private compareReachability(a: Vulnerability, b: Vulnerability): number {
const order: Record<NonNullable<Vulnerability['reachabilityStatus']>, 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<void> {
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<HTMLElement>(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<boolean> {
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 { private buildMockAttestation(vuln: Vulnerability, artifactId: string): TriageAttestationDetail {
const verified = vuln.status !== 'open'; const verified = vuln.status !== 'open';
const signer = verified const signer = verified

View File

@@ -38,5 +38,11 @@ describe('VexDecisionModalComponent', () => {
component.save(); component.save();
expect(api.createDecision).toHaveBeenCalled(); 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');
});
}); });

View File

@@ -28,6 +28,7 @@ import type { VexDecisionCreateRequest } from '../../core/api/vex-decisions.mode
const STATUS_OPTIONS: readonly { value: VexStatus; label: string }[] = [ const STATUS_OPTIONS: readonly { value: VexStatus; label: string }[] = [
{ value: 'NOT_AFFECTED', label: 'Not Affected' }, { value: 'NOT_AFFECTED', label: 'Not Affected' },
{ value: 'UNDER_INVESTIGATION', label: 'Under Investigation' },
{ value: 'AFFECTED_MITIGATED', label: 'Affected (mitigated)' }, { value: 'AFFECTED_MITIGATED', label: 'Affected (mitigated)' },
{ value: 'AFFECTED_UNMITIGATED', label: 'Affected (unmitigated)' }, { value: 'AFFECTED_UNMITIGATED', label: 'Affected (unmitigated)' },
{ value: 'FIXED', label: 'Fixed' }, { value: 'FIXED', label: 'Fixed' },
@@ -85,6 +86,7 @@ export class VexDecisionModalComponent {
readonly vulnerabilityIds = input.required<readonly string[]>(); readonly vulnerabilityIds = input.required<readonly string[]>();
readonly availableAttestationIds = input<readonly string[]>([]); readonly availableAttestationIds = input<readonly string[]>([]);
readonly existingDecision = input<VexDecision | null>(null); readonly existingDecision = input<VexDecision | null>(null);
readonly initialStatus = input<VexStatus | null>(null);
readonly closed = output<void>(); readonly closed = output<void>();
readonly saved = output<readonly VexDecision[]>(); readonly saved = output<readonly VexDecision[]>();
@@ -157,19 +159,28 @@ export class VexDecisionModalComponent {
}); });
constructor() { constructor() {
effect(() => { effect(
const existing = this.existingDecision(); () => {
if (!existing) return; 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); const initialStatus = this.initialStatus();
this.justificationType.set(existing.justificationType); if (initialStatus) {
this.justificationText.set(existing.justificationText ?? ''); this.status.set(initialStatus);
this.environmentsText.set(existing.scope?.environments?.join(', ') ?? ''); }
this.projectsText.set(existing.scope?.projects?.join(', ') ?? ''); },
this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString())); { allowSignalWrites: true }
this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? '')); );
this.evidenceRefs.set(existing.evidenceRefs ?? []);
});
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {