Compare commits
7 Commits
505fe7a885
...
41864227d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41864227d2 | ||
|
|
8137503221 | ||
|
|
08dab053c0 | ||
|
|
7ce83270d0 | ||
|
|
0cb5c9abfb | ||
|
|
d59cc816c1 | ||
|
|
4344020dd1 |
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
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.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)
|
||||||
|
|||||||
@@ -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. |
|
| 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). |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -535,25 +535,24 @@
|
|||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
← Previous
|
← 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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) {
|
} @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"
|
||||||
· 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>
|
||||||
|
· 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user