up
This commit is contained in:
@@ -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<Unknow
|
||||
|
||||
| # | Task | Status | Assignee | Notes |
|
||||
|---|------|--------|----------|-------|
|
||||
| 1 | Create migration file `V1102_001` | TODO | | Per §3.1 |
|
||||
| 2 | Add scoring columns to unknowns table | TODO | | 5 factors + composite |
|
||||
| 3 | Add band column with CHECK constraint | TODO | | hot/warm/cold |
|
||||
| 4 | Add JSONB columns (flags, trace) | TODO | | |
|
||||
| 5 | Add rescan scheduling columns | TODO | | |
|
||||
| 6 | Create indexes for efficient queries | TODO | | 6 indexes |
|
||||
| 7 | Update `UnknownEntity` class | TODO | | Per §3.4 |
|
||||
| 8 | Update EF Core configuration | TODO | | Per §3.5 |
|
||||
| 9 | Create JSON schemas for flags/trace | TODO | | Per §3.2, §3.3 |
|
||||
| 10 | Write migration tests | TODO | | Verify upgrade/downgrade |
|
||||
| 11 | Document schema in `docs/db/` | TODO | | Add to SPECIFICATION.md |
|
||||
| 1 | Create migration file `V1102_001` | DONE | | Per §3.1 |
|
||||
| 2 | Add scoring columns to unknowns table | DONE | | 5 factors + composite in EnsureTableAsync |
|
||||
| 3 | Add band column with CHECK constraint | DONE | | hot/warm/cold |
|
||||
| 4 | Add JSONB columns (flags, trace) | DONE | | |
|
||||
| 5 | Add rescan scheduling columns | DONE | | |
|
||||
| 6 | Create indexes for efficient queries | DONE | | 9 indexes created |
|
||||
| 7 | Update `UnknownEntity` class | DONE | | Model already existed in UnknownSymbolDocument |
|
||||
| 8 | Update EF Core configuration | N/A | | Using raw SQL with Npgsql, not EF Core |
|
||||
| 9 | Create JSON schemas for flags/trace | DONE | | Per §3.2, §3.3 - documented in migration |
|
||||
| 10 | Write migration tests | DONE | | 4 tests passing |
|
||||
| 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
|
||||
|
||||
- [ ] All scoring columns present with correct types
|
||||
- [ ] Range constraints enforce [0.0, 1.0] bounds
|
||||
- [ ] Band constraint enforces 'hot', 'warm', 'cold' only
|
||||
- [ ] JSONB columns accept valid JSON
|
||||
- [ ] Indexes created and functional
|
||||
- [x] All scoring columns present with correct types
|
||||
- [x] Range constraints enforce [0.0, 1.0] bounds
|
||||
- [x] Band constraint enforces 'hot', 'warm', 'cold' only
|
||||
- [x] JSONB columns accept valid JSON
|
||||
- [x] Indexes created and functional
|
||||
|
||||
### 5.2 Migration Requirements
|
||||
|
||||
- [ ] Migration is idempotent (re-runnable)
|
||||
- [ ] Migration supports rollback
|
||||
- [ ] Existing data preserved during upgrade
|
||||
- [ ] Default values applied correctly
|
||||
- [x] Migration is idempotent (re-runnable) - using IF NOT EXISTS
|
||||
- [x] Migration supports rollback - via EnsureTableAsync recreation
|
||||
- [x] Existing data preserved during upgrade - additive columns only
|
||||
- [x] Default values applied correctly
|
||||
|
||||
### 5.3 Code Requirements
|
||||
|
||||
- [ ] Entity class maps all columns
|
||||
- [ ] EF Core configuration matches schema
|
||||
- [ ] Repository can query by band
|
||||
- [ ] Repository can query by score descending
|
||||
- [x] Entity class maps all columns (UnknownSymbolDocument)
|
||||
- [x] Repository uses raw SQL with Npgsql (not EF Core)
|
||||
- [x] Repository can query by band (GetDueForRescanAsync)
|
||||
- [x] Repository can query by score descending (GetBySubjectAsync)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ The Triage & Unknowns system transforms StellaOps from a static vulnerability re
|
||||
|
||||
| Sprint | ID | Topic | Status | Dependencies |
|
||||
|--------|-----|-------|--------|--------------|
|
||||
| 1 | SPRINT_1102_0001_0001 | Database Schema: Unknowns Scoring & Metrics Tables | TODO | None |
|
||||
| 2 | SPRINT_1103_0001_0001 | Replay Token Library | TODO | None |
|
||||
| 3 | SPRINT_1104_0001_0001 | Evidence Bundle Envelope Schema | TODO | Attestor.Types |
|
||||
| 1 | SPRINT_1102_0001_0001 | Database Schema: Unknowns Scoring & Metrics Tables | DONE | None |
|
||||
| 2 | SPRINT_1103_0001_0001 | Replay Token Library | DONE | None |
|
||||
| 3 | SPRINT_1104_0001_0001 | Evidence Bundle Envelope Schema | DONE | Attestor.Types |
|
||||
|
||||
### Priority P0 - Must Have (Backend)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sprint 4601_0001_0001 · Keyboard Shortcuts for Triage UI
|
||||
|
||||
**Status:** DOING
|
||||
**Status:** DONE
|
||||
**Priority:** P1 - HIGH
|
||||
**Module:** Web (Angular)
|
||||
**Working Directory:** `src/Web/StellaOps.Web/src/app/features/triage/`
|
||||
@@ -26,25 +26,26 @@
|
||||
## Delivery Tracker
|
||||
| # | 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). |
|
||||
| 2 | UI-TRIAGE-4601-002 | TODO | 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`). |
|
||||
| 4 | UI-TRIAGE-4601-004 | TODO | 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`, `?`). |
|
||||
| 6 | UI-TRIAGE-4601-006 | TODO | Workspace focus management | Web Guild | Implement arrow navigation. |
|
||||
| 7 | UI-TRIAGE-4601-007 | TODO | 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). |
|
||||
| 9 | UI-TRIAGE-4601-009 | TODO | 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). |
|
||||
| 11 | UI-TRIAGE-4601-011 | TODO | Docs update | Web Guild · Docs | Document shortcuts in the UI user guide. |
|
||||
| 1 | UI-TRIAGE-4601-001 | DONE | Implement global keyboard listener | Web Guild | Create `KeyboardShortcutsService` (per Technical Design §3.1). |
|
||||
| 2 | UI-TRIAGE-4601-002 | DONE | Register triage mappings | Web Guild | Create `TriageShortcutsService` (per Technical Design §3.2). |
|
||||
| 3 | UI-TRIAGE-4601-003 | DONE | Wire into workspace component | Web Guild | Implement navigation shortcuts (`J`, `/`, `R`, `S`). |
|
||||
| 4 | UI-TRIAGE-4601-004 | DONE | Decide VEX mapping for `U` | Web Guild | Implement decision shortcuts (`A`, `N`, `U`). |
|
||||
| 5 | UI-TRIAGE-4601-005 | DONE | Clipboard implementation | Web Guild | Implement utility shortcuts (`Y`, `?`). |
|
||||
| 6 | UI-TRIAGE-4601-006 | DONE | Workspace focus management | Web Guild | Implement arrow navigation. |
|
||||
| 7 | UI-TRIAGE-4601-007 | DONE | Modal/overlay wiring | Web Guild | Create keyboard help overlay. |
|
||||
| 8 | UI-TRIAGE-4601-008 | DONE | Update templates | Web Guild | Add accessibility attributes (ARIA, focusable cards, tab semantics). |
|
||||
| 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 | DONE | Karma specs | Web Guild · QA | Write unit tests for key flows (registration, focus gating, handlers). |
|
||||
| 11 | UI-TRIAGE-4601-011 | DONE | Docs update | Web Guild · Docs | Document shortcuts in the UI user guide. |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 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
|
||||
- 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
|
||||
- N/A.
|
||||
@@ -518,17 +519,17 @@ export class KeyboardHelpComponent {
|
||||
|
||||
| # | Task | Status | Assignee | Notes |
|
||||
|---|------|--------|----------|-------|
|
||||
| 1 | Create `KeyboardShortcutsService` | TODO | | Per §3.1 |
|
||||
| 2 | Create `TriageShortcutsService` | TODO | | Per §3.2 |
|
||||
| 3 | Implement navigation shortcuts (J, /, R, S) | TODO | | |
|
||||
| 4 | Implement decision shortcuts (A, N, U) | TODO | | |
|
||||
| 5 | Implement utility shortcuts (Y, ?) | TODO | | |
|
||||
| 6 | Implement arrow navigation | TODO | | |
|
||||
| 7 | Create keyboard help overlay | TODO | | Per §3.3 |
|
||||
| 8 | Add accessibility attributes | TODO | | ARIA |
|
||||
| 9 | Handle input field focus | TODO | | Disable when typing |
|
||||
| 10 | Write unit tests | TODO | | |
|
||||
| 11 | Document shortcuts in user guide | TODO | | |
|
||||
| 1 | Create `KeyboardShortcutsService` | DONE | | Per §3.1 |
|
||||
| 2 | Create `TriageShortcutsService` | DONE | | Per §3.2 |
|
||||
| 3 | Implement navigation shortcuts (J, /, R, S) | DONE | | |
|
||||
| 4 | Implement decision shortcuts (A, N, U) | DONE | | |
|
||||
| 5 | Implement utility shortcuts (Y, ?) | DONE | | |
|
||||
| 6 | Implement arrow navigation | DONE | | |
|
||||
| 7 | Create keyboard help overlay | DONE | | Per §3.3 |
|
||||
| 8 | Add accessibility attributes | DONE | | ARIA |
|
||||
| 9 | Handle input field focus | DONE | | Disable when typing |
|
||||
| 10 | Write unit tests | DONE | | |
|
||||
| 11 | Document shortcuts in user guide | DONE | | |
|
||||
|
||||
---
|
||||
|
||||
@@ -536,16 +537,16 @@ export class KeyboardHelpComponent {
|
||||
|
||||
### 5.1 Shortcut Requirements
|
||||
|
||||
- [ ] All 7 advisory shortcuts implemented
|
||||
- [ ] Shortcuts disabled when typing in inputs
|
||||
- [ ] Help overlay shows all shortcuts
|
||||
- [ ] Shortcuts work across all triage views
|
||||
- [x] All 7 advisory shortcuts implemented
|
||||
- [x] Shortcuts disabled when typing in inputs
|
||||
- [x] Help overlay shows all shortcuts
|
||||
- [x] Shortcuts work across all triage views
|
||||
|
||||
### 5.2 Accessibility Requirements
|
||||
|
||||
- [ ] Standard keyboard navigation patterns
|
||||
- [ ] ARIA labels on interactive elements
|
||||
- [ ] Focus management correct
|
||||
- [x] Standard keyboard navigation patterns
|
||||
- [x] ARIA labels on interactive elements
|
||||
- [x] Focus management correct
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["NOT_AFFECTED", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"],
|
||||
"enum": ["NOT_AFFECTED", "UNDER_INVESTIGATION", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"],
|
||||
"description": "VEX status"
|
||||
},
|
||||
"path": {
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"NOT_AFFECTED",
|
||||
"UNDER_INVESTIGATION",
|
||||
"AFFECTED_MITIGATED",
|
||||
"AFFECTED_UNMITIGATED",
|
||||
"FIXED"
|
||||
|
||||
50
docs/ui/triage.md
Normal file
50
docs/ui/triage.md
Normal 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`
|
||||
|
||||
@@ -201,6 +201,12 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
}
|
||||
});
|
||||
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<IDeltaScanRequestHandler, DeltaScanRequestHandler>();
|
||||
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -144,7 +144,12 @@ export interface AocChainEntry {
|
||||
}
|
||||
|
||||
// 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 =
|
||||
| 'CODE_NOT_PRESENT'
|
||||
@@ -204,6 +209,7 @@ export interface VexDecision {
|
||||
// VEX status summary for UI display
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly underInvestigation: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
|
||||
@@ -539,16 +539,15 @@
|
||||
|
||||
<!-- Page number buttons (show max 5) -->
|
||||
@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
|
||||
type="button"
|
||||
class="pagination-btn pagination-btn--number"
|
||||
[class.active]="currentPage() === pageNum"
|
||||
(click)="goToPage(pageNum)"
|
||||
[attr.aria-current]="currentPage() === pageNum ? 'page' : null"
|
||||
aria-label="Page {{ pageNum + 1 }}"
|
||||
[class.active]="currentPage() === getPageNumberForIndex(i)"
|
||||
(click)="goToPage(getPageNumberForIndex(i))"
|
||||
[attr.aria-current]="currentPage() === getPageNumberForIndex(i) ? 'page' : null"
|
||||
aria-label="Page {{ getPageNumberForIndex(i) + 1 }}"
|
||||
>
|
||||
{{ pageNum + 1 }}
|
||||
{{ getPageNumberForIndex(i) + 1 }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -765,6 +764,10 @@
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
|
||||
<span class="vex-summary-card__label">Not Affected</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--under-investigation">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().underInvestigation }}</span>
|
||||
<span class="vex-summary-card__label">Under Investigation</span>
|
||||
</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>
|
||||
|
||||
@@ -1459,6 +1459,15 @@ $color-text-muted: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
&--under-investigation {
|
||||
background: #f5f3ff;
|
||||
border-color: #c4b5fd;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #6d28d9;
|
||||
}
|
||||
}
|
||||
|
||||
&--mitigated {
|
||||
background: #fef9c3;
|
||||
border-color: #fde047;
|
||||
@@ -1621,6 +1630,11 @@ $color-text-muted: #6b7280;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.vex-status--under-investigation {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
&.vex-status--mitigated {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +174,15 @@ export class EvidencePanelComponent {
|
||||
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1);
|
||||
readonly hasPreviousPage = computed(() => this.currentPage() > 0);
|
||||
|
||||
getPageNumberForIndex(i: number): number {
|
||||
const totalPages = this.totalPages();
|
||||
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();
|
||||
@@ -199,6 +208,7 @@ export class EvidencePanelComponent {
|
||||
const decisions = this.vexDecisions();
|
||||
return {
|
||||
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
|
||||
underInvestigation: decisions.filter((d) => d.status === 'UNDER_INVESTIGATION').length,
|
||||
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
|
||||
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
|
||||
fixed: decisions.filter((d) => d.status === 'FIXED').length,
|
||||
@@ -444,6 +454,8 @@ export class EvidencePanelComponent {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'Not Affected';
|
||||
case 'UNDER_INVESTIGATION':
|
||||
return 'Under Investigation';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'Affected (Mitigated)';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
@@ -459,6 +471,8 @@ export class EvidencePanelComponent {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'vex-status--not-affected';
|
||||
case 'UNDER_INVESTIGATION':
|
||||
return 'vex-status--under-investigation';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'vex-status--mitigated';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,12 +38,18 @@
|
||||
} @else if (findings().length === 0) {
|
||||
<div class="empty">No findings for this artifact.</div>
|
||||
} @else {
|
||||
<div class="cards">
|
||||
@for (finding of findings(); track finding.vuln.vulnId) {
|
||||
<div class="cards" role="listbox" aria-label="Findings">
|
||||
@for (finding of findings(); track finding.vuln.vulnId; let i = $index) {
|
||||
<article
|
||||
class="card"
|
||||
[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)"
|
||||
(focus)="selectFinding(finding.vuln.vulnId, { resetTab: false })"
|
||||
>
|
||||
<header class="card__header">
|
||||
<label class="bulk-check" (click)="$event.stopPropagation()">
|
||||
@@ -94,18 +100,71 @@
|
||||
</aside>
|
||||
|
||||
<section class="right">
|
||||
<header class="tabs">
|
||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'overview'" (click)="setTab('overview')">Overview</button>
|
||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'reachability'" (click)="setTab('reachability')">Reachability</button>
|
||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'policy'" (click)="setTab('policy')">Policy</button>
|
||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'attestations'" (click)="setTab('attestations')">Attestations</button>
|
||||
<header class="tabs" role="tablist" aria-label="Evidence tabs">
|
||||
<button
|
||||
id="triage-tab-overview"
|
||||
type="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>
|
||||
|
||||
<div class="panel">
|
||||
@if (!selectedVuln()) {
|
||||
<div class="empty">Select a finding to view evidence.</div>
|
||||
} @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>
|
||||
<p class="muted">{{ selectedVuln()!.vuln.title }}</p>
|
||||
<dl class="kv">
|
||||
@@ -149,18 +208,94 @@
|
||||
}
|
||||
</section>
|
||||
} @else if (activeTab() === 'reachability') {
|
||||
<section class="section">
|
||||
<section
|
||||
id="triage-panel-reachability"
|
||||
class="section"
|
||||
role="tabpanel"
|
||||
aria-labelledby="triage-tab-reachability"
|
||||
>
|
||||
<header class="reachability-header">
|
||||
<div>
|
||||
<h3>Reachability</h3>
|
||||
<p class="hint">
|
||||
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong>
|
||||
· score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }}
|
||||
</p>
|
||||
<button type="button" class="btn btn--secondary" (click)="openReachabilityDrawer()" [disabled]="!selectedVuln()!.component">
|
||||
</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>
|
||||
} @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>
|
||||
<p class="hint">Deterministic stub: replace with Policy Engine evaluation data.</p>
|
||||
|
||||
@@ -216,7 +351,12 @@
|
||||
}
|
||||
</section>
|
||||
} @else if (activeTab() === 'attestations') {
|
||||
<section class="section">
|
||||
<section
|
||||
id="triage-panel-attestations"
|
||||
class="section"
|
||||
role="tabpanel"
|
||||
aria-labelledby="triage-tab-attestations"
|
||||
>
|
||||
<h3>Attestations</h3>
|
||||
@if (attestationsForSelected().length === 0) {
|
||||
<p class="hint">No attestations found for this finding.</p>
|
||||
@@ -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()) {
|
||||
<app-keyboard-help (closed)="showKeyboardHelp.set(false)" />
|
||||
}
|
||||
|
||||
@if (keyboardStatus(); as status) {
|
||||
<div class="kbd-toast" role="status" aria-live="polite">{{ status }}</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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<VulnerabilitySeverity, number> = {
|
||||
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<HTMLElement>>(ElementRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||
@@ -96,6 +104,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
readonly showVexModal = signal(false);
|
||||
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
|
||||
readonly vexModalInitialStatus = signal<VexStatus | null>(null);
|
||||
readonly vexExistingDecision = signal<VexDecision | null>(null);
|
||||
|
||||
readonly showReachabilityDrawer = signal(false);
|
||||
readonly reachabilityComponent = signal<string | null>(null);
|
||||
@@ -109,6 +118,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
readonly findingsSort = signal<'default' | 'deterministic'>('default');
|
||||
readonly keyboardStatus = signal<string | null>(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<void> {
|
||||
@@ -289,10 +301,12 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
selectFinding(vulnId: string): void {
|
||||
selectFinding(vulnId: string, options?: { resetTab?: boolean }): void {
|
||||
this.selectedVulnId.set(vulnId);
|
||||
if (options?.resetTab ?? true) {
|
||||
this.activeTab.set('overview');
|
||||
}
|
||||
}
|
||||
|
||||
toggleBulkSelection(vulnId: string): void {
|
||||
const current = new Set(this.selectedForBulk());
|
||||
@@ -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<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 {
|
||||
const verified = vuln.status !== 'open';
|
||||
const signer = verified
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 string[]>();
|
||||
readonly availableAttestationIds = input<readonly string[]>([]);
|
||||
readonly existingDecision = input<VexDecision | null>(null);
|
||||
readonly initialStatus = input<VexStatus | null>(null);
|
||||
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<readonly VexDecision[]>();
|
||||
@@ -157,10 +159,10 @@ export class VexDecisionModalComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
effect(
|
||||
() => {
|
||||
const existing = this.existingDecision();
|
||||
if (!existing) return;
|
||||
|
||||
if (existing) {
|
||||
this.status.set(existing.status);
|
||||
this.justificationType.set(existing.justificationType);
|
||||
this.justificationText.set(existing.justificationText ?? '');
|
||||
@@ -169,7 +171,16 @@ export class VexDecisionModalComponent {
|
||||
this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString()));
|
||||
this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? ''));
|
||||
this.evidenceRefs.set(existing.evidenceRefs ?? []);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const initialStatus = this.initialStatus();
|
||||
if (initialStatus) {
|
||||
this.status.set(initialStatus);
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
||||
Reference in New Issue
Block a user