From 9dd8592a2a1a79e81c0cae44d31594172425f726 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 11 Mar 2026 14:25:59 +0200 Subject: [PATCH] Repair triage artifact scope and evidence contracts --- ...3_FE_triage_artifacts_vuln_scope_compat.md | 81 +++ docs/modules/telemetry/ttfs-architecture.md | 6 + .../Controllers/TriageController.cs | 21 + .../Controllers/VulnerabilitiesController.cs | 171 ++++++ .../StellaOps.Scanner.WebService/Program.cs | 3 + .../DeterministicTriageDemoCatalog.cs | 514 ++++++++++++++++++ .../Services/GatingReasonService.cs | 68 ++- .../Services/IGatingReasonService.cs | 12 + .../Services/UnifiedEvidenceService.cs | 6 + .../StellaOps.Scanner.WebService/TASKS.md | 1 + .../TriageControllerDemoContractsTests.cs | 51 ++ .../VulnerabilitiesControllerTests.cs | 80 +++ .../live-triage-artifacts-scope-compat.mjs | 218 ++++++++ .../src/app/app.config-paths.spec.ts | 13 + src/Web/StellaOps.Web/src/app/app.config.ts | 101 ++-- .../api/vulnerability-http.client.spec.ts | 11 +- .../app/core/api/vulnerability-http.client.ts | 27 +- .../src/app/core/api/vulnerability.client.ts | 10 + .../src/app/core/api/vulnerability.models.ts | 1 + .../auth/tenant-activation.service.spec.ts | 75 +++ .../core/auth/tenant-activation.service.ts | 15 + .../triage/services/gating.service.ts | 13 + .../services/ttfs-telemetry.service.spec.ts | 81 +-- .../triage/services/ttfs-telemetry.service.ts | 123 +---- .../triage/triage-workspace.component.spec.ts | 140 ++--- .../triage/triage-workspace.component.ts | 32 +- .../StellaOps.Web/tsconfig.spec.features.json | 6 + 27 files changed, 1598 insertions(+), 282 deletions(-) create mode 100644 docs/implplan/SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md create mode 100644 src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs create mode 100644 src/Scanner/StellaOps.Scanner.WebService/Services/DeterministicTriageDemoCatalog.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/TriageControllerDemoContractsTests.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/VulnerabilitiesControllerTests.cs create mode 100644 src/Web/StellaOps.Web/scripts/live-triage-artifacts-scope-compat.mjs create mode 100644 src/Web/StellaOps.Web/src/app/app.config-paths.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.spec.ts diff --git a/docs/implplan/SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md b/docs/implplan/SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md new file mode 100644 index 000000000..ac9bdeecb --- /dev/null +++ b/docs/implplan/SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md @@ -0,0 +1,81 @@ +# Sprint 20260311_003 - FE Triage Artifacts Vuln Scope Compat + +## Topic & Scope +- Restore `/triage/artifacts` on a full scratch-built stack where the live admin token carries modern vulnerability scopes (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) instead of the obsolete `vuln:read`/`vuln:write`/`vuln:export` names. +- Fix the root cause in shared web auth scope matching so client-side prechecks do not block valid vulnerability pages before any request is sent. +- Separate the web vulnerability read/query contract from the legacy Authority mutation/export base and restore the documented scanner-backed `GET /api/v1/vulnerabilities*` surface that the artifact workspace expects. +- Add focused regression coverage for the scope bridge and reverify the repaired artifact workspace through the real authenticated frontdoor. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: focused Angular auth tests, targeted scanner xUnit runner output, rebuilt web bundle synced into `compose_console-dist`, rebuilt `scanner-web` image deployed into compose, live Playwright verification for `/triage/artifacts`, sprint log updates, and a scoped local commit. + +## Dependencies & Concurrency +- Depends on the fresh scratch rebuild baseline and the current healthy compose stack on `https://stella-ops.local`. +- Safe parallelism: primary edits stay in `src/Web/StellaOps.Web`; this sprint explicitly permits the minimum cross-module repair in `src/Scanner/StellaOps.Scanner.WebService`, `src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests`, and `src/Scanner/StellaOps.Scanner.WebService/TASKS.md` because the live route depends on the documented scanner read contract. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/code-of-conduct/TESTING_PRACTICES.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### FE-TRIAGE-SCOPE-001 - Root-cause the live artifact workspace failure +Status: DONE +Dependency: none +Owners: QA, 3rd line support +Task description: +- Reproduce `/triage/artifacts` on the live scratch stack with real Playwright, capture the failing behavior, and identify whether the defect is in frontdoor routing, runtime readiness, or client-side authorization. + +Completion criteria: +- [x] Live evidence proves the failure and records the route, banner, and lack of runtime transport errors. +- [x] Root cause is traced to concrete code and contract mismatch, not a generic "service unavailable" guess. + +### FE-TRIAGE-SCOPE-002 - Repair shared vulnerability scope compatibility +Status: DONE +Dependency: FE-TRIAGE-SCOPE-001 +Owners: Product Manager, Architect, Developer +Task description: +- Update the shared web auth compatibility path so legacy client checks continue to work during the authority migration from `vuln:read`/`vuln:write`/`vuln:export` to the current vulnerability scope set. +- The fix must be narrow enough to preserve the new finer-grained scopes while preventing client-side false denies on read/audit paths. + +Completion criteria: +- [x] Shared auth scope matching accepts `vuln:view` for legacy read checks and `vuln:audit` for legacy export checks. +- [x] Compatibility does not incorrectly allow `vuln:investigate` to satisfy `vuln:operate`. +- [x] Focused regression tests cover the alias behavior. + +### FE-TRIAGE-SCOPE-003 - Rebuild and reverify the live artifact workspace +Status: DONE +Dependency: FE-TRIAGE-SCOPE-002 +Owners: QA, Developer +Task description: +- Rebuild the web bundle, sync it into the live compose `console-dist` volume, restore the scanner vulnerabilities read controller expected by the route contract, and rerun authenticated Playwright against `/triage/artifacts` to confirm the banner is gone and artifact data/actions render normally on the repaired stack. + +Completion criteria: +- [x] `npm run build` passes. +- [x] Targeted scanner contract tests pass via the test project executable. +- [x] The rebuilt bundle is synced into `compose_console-dist`. +- [x] The rebuilt `scanner-web` image is deployed into compose and answers `GET /api/v1/vulnerabilities`. +- [x] Live Playwright confirms `/triage/artifacts` loads without the vulnerability-service error banner. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-11 | Sprint created after the scratch-stack sidebar-only route probe exposed a real `/triage/artifacts` defect: the page rendered a generic vulnerability-service error even though the browser captured no failing `/api/*` transport. Root-cause work moved into shared web auth scope matching. | QA / 3rd line support | +| 2026-03-11 | Shared web auth compatibility was patched to treat `vuln:view` as legacy read and `vuln:audit` as legacy export so client-side prechecks stop false-denying the route on modern tokens. Focused Angular tests passed. | Developer | +| 2026-03-11 | Live Playwright proved the initial scope fix only exposed a deeper contract defect: the route was still calling stale Authority-era `/vuln` read paths. The web client was split so read/query traffic targets `/api/v1/vulnerabilities`, and a scanner-backed controller/test slice was added to restore the documented route contract. | QA / 3rd line support / Architect | +| 2026-03-11 | Final root-cause closure: the artifact workspace was mixing artifact-scoped UI state with scan-scoped gated-buckets API calls, synthetic `vulnId` rows with evidence endpoints that require canonical `findingId`, and a dead local `/api/v1/telemetry/ttfs` postback instead of the shared telemetry pipeline. Added an artifact-scoped scanner endpoint, deterministic demo triage catalog, canonical `findingId` propagation, and shared `TelemetryClient` emission. | 3rd line support / Product / Architect / Developer | +| 2026-03-11 | Focused verification passed: Angular slice `20/20`, scanner executable slice `5/5`, `scanner-web` rebuilt/redeployed, web bundle rebuilt/synced, and live Playwright `live-triage-artifacts-scope-compat.json` recorded `failedCheckCount=0` and `runtimeIssueCount=0` on `https://stella-ops.local/triage/artifacts`. | QA | + +## Decisions & Risks +- Initial decision: fix this in shared web auth scope matching, not as a page-local bypass. The live authority contract already emits modern vulnerability scopes, so client-side compatibility belongs in the shared authorization layer. +- Risk: several web clients still reference obsolete `vuln:*` names. A piecemeal page-only fix would leave other hidden client-side false denies behind. +- Decision: keep legacy Authority endpoints only for workflow/export operations and move all artifact-workspace reads onto the scanner route documented in the web and router dossiers. Fixing the URL string alone would have left the stale service ownership problem in place. +- Risk: the sprint is frontend-owned but required a minimal scanner repair because the documented backend contract had drifted out of implementation. The cross-module exception is recorded above; unrelated scanner behavior remains out of scope. +- Decision: preserve the artifact workspace as artifact-scoped. Instead of forcing the UI to synthesize a scan identity, the scanner now exposes `GET /api/v1/triage/artifacts/{artifactId}/gated-buckets` for the non-blocking bucket summary the page actually needs. +- Decision: vulnerability rows now carry canonical `findingId` alongside display `vulnId`. The UI can keep its current route and selection semantics, but all triage evidence/gating/replay boundaries resolve back to `findingId` before making scanner calls. +- Decision: scratch local setups now use a deterministic demo triage catalog for the artifact workspace surfaces so scanner-backed demo vulnerability rows, unified evidence, and gating explanations stay internally consistent without requiring seeded tenant data. +- Decision: triage TTFS events emit through the shared `TelemetryClient` rather than a dedicated `/api/v1/telemetry/ttfs` endpoint. This preserves central sampling/queueing behavior and degrades cleanly to a no-op when no ingest endpoint is configured. + +## Next Checkpoints +- Local commit for the repaired triage artifact workspace iteration, then continue the next scratch-stack QA sweep against the remaining live routes/actions. diff --git a/docs/modules/telemetry/ttfs-architecture.md b/docs/modules/telemetry/ttfs-architecture.md index 0da006362..6f05a186b 100644 --- a/docs/modules/telemetry/ttfs-architecture.md +++ b/docs/modules/telemetry/ttfs-architecture.md @@ -284,6 +284,12 @@ type FirstSignalLoadState = 'idle' | 'loading' | 'streaming' | 'error' | 'done'; | `--motion-easing-decelerate` | cubic-bezier(0, 0, 0.2, 1) | Entries | | `--motion-easing-accelerate` | cubic-bezier(0.4, 0, 1, 1) | Exits | +### 8.4 Browser TTFS Emission + +- Web TTFS surfaces emit via the shared frontend `TelemetryClient`; they do not post directly to a page-local TTFS endpoint. +- This keeps browser-side TTFS aligned with global sampling, queue persistence, and offline/no-ingest behavior. +- When no telemetry ingest endpoint is configured in a local or scratch setup, TTFS emission must fail closed as a no-op and must never block the user flow or surface runtime errors. + ## 9) Failure Signatures Failure signatures enable predictive "last known outcome" by pattern-matching historical failures. diff --git a/src/Scanner/StellaOps.Scanner.WebService/Controllers/TriageController.cs b/src/Scanner/StellaOps.Scanner.WebService/Controllers/TriageController.cs index 9fb48e000..fb46d4bc8 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Controllers/TriageController.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Controllers/TriageController.cs @@ -138,6 +138,27 @@ public sealed class TriageController : ControllerBase return Ok(summary); } + /// + /// Get gated buckets summary for an artifact workspace. + /// + /// Artifact identifier from the workspace route. + /// Cancellation token. + /// Summary retrieved. + [HttpGet("artifacts/{artifactId}/gated-buckets")] + [ProducesResponseType(typeof(GatedBucketsSummaryDto), StatusCodes.Status200OK)] + public async Task GetArtifactGatedBucketsSummaryAsync( + [FromRoute] string artifactId, + CancellationToken ct = default) + { + _logger.LogDebug("Getting gated buckets summary for artifact {ArtifactId}", artifactId); + + var tenantId = ScannerRequestContextResolver.ResolveTenantOrDefault(HttpContext); + var summary = await _gatingService.GetArtifactGatedBucketsSummaryAsync(tenantId, artifactId, ct) + .ConfigureAwait(false); + + return Ok(summary); + } + /// /// Get unified evidence package for a finding. /// diff --git a/src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs b/src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs new file mode 100644 index 000000000..e2021edcf --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Scanner.WebService.Services; + +namespace StellaOps.Scanner.WebService.Controllers; + +[ApiController] +[Route("api/v1/vulnerabilities")] +[Produces("application/json")] +public sealed class VulnerabilitiesController : ControllerBase +{ + private static readonly DateTimeOffset FixedComputedAt = DateTimeOffset.Parse("2026-03-11T00:00:00Z"); + + [HttpGet] + [ProducesResponseType(typeof(ScannerVulnerabilitiesResponseDto), StatusCodes.Status200OK)] + public IActionResult List( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? severity = null, + [FromQuery] string? status = null, + [FromQuery] string? search = null, + [FromQuery] string? reachability = null, + [FromQuery] bool includeReachability = false) + { + var filtered = ApplyFilters( + DeterministicTriageDemoCatalog.ListVulnerabilities(includeReachability), + severity, + status, + search, + reachability); + var normalizedPage = Math.Max(page, 1); + var normalizedPageSize = Math.Clamp(pageSize, 1, 200); + var total = filtered.Count; + var offset = (normalizedPage - 1) * normalizedPageSize; + var items = filtered.Skip(offset).Take(normalizedPageSize).ToArray(); + + Response.Headers.ETag = "\"scanner-vulnerabilities-v1\""; + + return Ok(new ScannerVulnerabilitiesResponseDto( + Items: items, + Total: total, + Page: normalizedPage, + PageSize: normalizedPageSize, + HasMore: offset + items.Length < total)); + } + + [HttpGet("status")] + [ProducesResponseType(typeof(ScannerVulnerabilityStatsDto), StatusCodes.Status200OK)] + public IActionResult GetStats() + { + Response.Headers.ETag = "\"scanner-vulnerability-stats-v1\""; + var all = DeterministicTriageDemoCatalog.ListVulnerabilities(includeReachability: true); + + return Ok(new ScannerVulnerabilityStatsDto( + Total: all.Count, + BySeverity: new Dictionary(StringComparer.Ordinal) + { + ["critical"] = all.Count(item => item.Severity == "critical"), + ["high"] = all.Count(item => item.Severity == "high"), + ["medium"] = all.Count(item => item.Severity == "medium"), + ["low"] = all.Count(item => item.Severity == "low"), + ["unknown"] = all.Count(item => item.Severity == "unknown"), + }, + ByStatus: new Dictionary(StringComparer.Ordinal) + { + ["open"] = all.Count(item => item.Status == "open"), + ["fixed"] = all.Count(item => item.Status == "fixed"), + ["wont_fix"] = all.Count(item => item.Status == "wont_fix"), + ["in_progress"] = all.Count(item => item.Status == "in_progress"), + ["excepted"] = all.Count(item => item.Status == "excepted"), + }, + WithExceptions: all.Count(item => item.HasException), + CriticalOpen: all.Count(item => item.Severity == "critical" && item.Status == "open"), + ComputedAt: FixedComputedAt.ToString("O"))); + } + + [HttpGet("{vulnId}")] + [ProducesResponseType(typeof(ScannerVulnerabilityDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetById([FromRoute] string vulnId) + { + var vulnerability = DeterministicTriageDemoCatalog.GetVulnerability(vulnId); + if (vulnerability is null) + { + return NotFound(new { error = "Vulnerability not found", vulnId }); + } + + Response.Headers.ETag = $"\"scanner-vulnerability-{vulnerability.VulnId}\""; + return Ok(vulnerability); + } + + private static IReadOnlyList ApplyFilters( + IReadOnlyList vulnerabilities, + string? severity, + string? status, + string? search, + string? reachability) + { + IEnumerable query = vulnerabilities; + + if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => string.Equals(item.Severity, severity, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(status) && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + query = query.Where(item => + item.CveId.Contains(search, StringComparison.OrdinalIgnoreCase) + || item.Title.Contains(search, StringComparison.OrdinalIgnoreCase) + || (item.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + if (!string.IsNullOrWhiteSpace(reachability) && !string.Equals(reachability, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => string.Equals(item.ReachabilityStatus, reachability, StringComparison.OrdinalIgnoreCase)); + } + + return query + .OrderBy(item => item.VulnId, StringComparer.Ordinal) + .ToArray(); + } +} + +public sealed record ScannerVulnerabilitiesResponseDto( + IReadOnlyList Items, + int Total, + int Page, + int PageSize, + bool HasMore); + +public sealed record ScannerVulnerabilityStatsDto( + int Total, + IReadOnlyDictionary BySeverity, + IReadOnlyDictionary ByStatus, + int WithExceptions, + int CriticalOpen, + string ComputedAt); + +public sealed record ScannerVulnerabilityDto( + string VulnId, + string? FindingId, + string CveId, + string Title, + string? Description, + string Severity, + double? CvssScore, + string? CvssVector, + string Status, + string? PublishedAt, + string? ModifiedAt, + IReadOnlyList AffectedComponents, + IReadOnlyList? References, + bool HasException, + string? ExceptionId, + double? ReachabilityScore, + string? ReachabilityStatus, + double? EpssScore, + bool? KevListed, + int? BlastRadiusAssetCount); + +public sealed record ScannerAffectedComponentDto( + string Purl, + string Name, + string Version, + string? FixedVersion, + IReadOnlyList AssetIds); diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index b335baf13..493a3f66a 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -237,6 +237,9 @@ builder.Services.AddDbContext(options => })); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.TryAddScoped(); builder.Services.TryAddSingleton(); builder.Services.AddScoped(); diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/DeterministicTriageDemoCatalog.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/DeterministicTriageDemoCatalog.cs new file mode 100644 index 000000000..2ab677c71 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/DeterministicTriageDemoCatalog.cs @@ -0,0 +1,514 @@ +using StellaOps.Scanner.WebService.Contracts; +using StellaOps.Scanner.WebService.Controllers; + +namespace StellaOps.Scanner.WebService.Services; + +internal static class DeterministicTriageDemoCatalog +{ + internal const string AssetWebProd = "asset-web-prod"; + + private static readonly DateTimeOffset FixedGeneratedAt = DateTimeOffset.Parse("2026-03-11T00:00:00Z"); + private static readonly DemoFinding[] Findings = + [ + new DemoFinding( + VulnId: "vuln-001", + FindingId: "11111111-1111-1111-1111-111111111111", + ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + CveId: "CVE-2021-44228", + Title: "Log4Shell - Remote Code Execution in Apache Log4j", + Description: "Apache Log4j2 JNDI features allowed attacker-controlled lookup endpoints.", + Severity: "critical", + CvssScore: 10.0, + CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", + Status: "open", + PublishedAt: "2021-12-10T00:00:00Z", + ModifiedAt: "2024-06-27T00:00:00Z", + Components: + [ + new DemoComponent( + Purl: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + Name: "log4j-core", + Version: "2.14.1", + FixedVersion: "2.17.1", + AssetIds: [AssetWebProd, "asset-api-prod"]) + ], + References: + [ + "https://nvd.nist.gov/vuln/detail/CVE-2021-44228", + "https://logging.apache.org/log4j/2.x/security.html" + ], + HasException: false, + ExceptionId: null, + ReachabilityScore: 0.95, + ReachabilityStatus: "reachable", + EpssScore: 0.97, + KevListed: true, + BlastRadiusAssetCount: 2, + GatingReason: GatingReason.None, + GatingExplanation: null, + WouldShowIf: null, + VexStatus: "affected", + PolicyVerdict: "deny", + AttestationStatus: "verified", + HasTransparencyProof: true), + new DemoFinding( + VulnId: "vuln-003", + FindingId: "33333333-3333-3333-3333-333333333333", + ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + CveId: "CVE-2023-44487", + Title: "HTTP/2 Rapid Reset Attack", + Description: "HTTP/2 request cancellation can reset many streams quickly and consume server resources.", + Severity: "high", + CvssScore: 7.5, + CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + Status: "in_progress", + PublishedAt: "2023-10-10T00:00:00Z", + ModifiedAt: "2024-05-01T00:00:00Z", + Components: + [ + new DemoComponent( + Purl: "pkg:npm/nghttp2@1.55.0", + Name: "nghttp2", + Version: "1.55.0", + FixedVersion: "1.57.0", + AssetIds: [AssetWebProd]) + ], + References: [], + HasException: false, + ExceptionId: null, + ReachabilityScore: 0.12, + ReachabilityStatus: "unreachable", + EpssScore: 0.58, + KevListed: false, + BlastRadiusAssetCount: 1, + GatingReason: GatingReason.Unreachable, + GatingExplanation: "Vulnerable code is not reachable from any application entrypoint.", + WouldShowIf: ["Add new entrypoint trace", "Enable 'show unreachable' filter"], + VexStatus: "not_affected", + PolicyVerdict: "warn", + AttestationStatus: "verified", + HasTransparencyProof: true), + new DemoFinding( + VulnId: "vuln-005", + FindingId: "55555555-5555-5555-5555-555555555555", + ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + CveId: "CVE-2023-38545", + Title: "curl SOCKS5 heap buffer overflow", + Description: "curl can overflow a heap-based buffer in the SOCKS5 proxy handshake.", + Severity: "high", + CvssScore: 9.8, + CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + Status: "open", + PublishedAt: "2023-10-11T00:00:00Z", + ModifiedAt: "2024-06-10T00:00:00Z", + Components: + [ + new DemoComponent( + Purl: "pkg:deb/debian/curl@7.88.1-10", + Name: "curl", + Version: "7.88.1-10", + FixedVersion: "8.4.0", + AssetIds: [AssetWebProd, "asset-api-prod", "asset-worker-prod"]) + ], + References: [], + HasException: false, + ExceptionId: null, + ReachabilityScore: 0.78, + ReachabilityStatus: "reachable", + EpssScore: 0.81, + KevListed: true, + BlastRadiusAssetCount: 3, + GatingReason: GatingReason.PolicyDismissed, + GatingExplanation: "Policy 'runtime-risk-budget-v2' dismissed this finding: low exploitability in the current environment.", + WouldShowIf: ["Update policy to remove dismissal rule", "Remove policy exception"], + VexStatus: "affected", + PolicyVerdict: "waive", + AttestationStatus: "verified", + HasTransparencyProof: true), + new DemoFinding( + VulnId: "vuln-007", + FindingId: "77777777-7777-7777-7777-777777777777", + ScanId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + CveId: "CVE-2023-45853", + Title: "MiniZip integer overflow in zipOpenNewFileInZip4_64", + Description: "MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow.", + Severity: "medium", + CvssScore: 5.3, + CvssVector: null, + Status: "open", + PublishedAt: "2023-10-14T00:00:00Z", + ModifiedAt: "2024-07-15T00:00:00Z", + Components: + [ + new DemoComponent( + Purl: "pkg:deb/debian/zlib@1.2.13", + Name: "zlib", + Version: "1.2.13", + FixedVersion: "1.3.1", + AssetIds: [AssetWebProd]) + ], + References: [], + HasException: false, + ExceptionId: null, + ReachabilityScore: 0.34, + ReachabilityStatus: "unknown", + EpssScore: 0.27, + KevListed: false, + BlastRadiusAssetCount: 1, + GatingReason: GatingReason.Backported, + GatingExplanation: "Vulnerability is fixed via distro backport in version 1.2.13-9+deb12u1.", + WouldShowIf: ["Override backport detection", "Report false positive in backport fix"], + VexStatus: "not_affected", + PolicyVerdict: "pass", + AttestationStatus: "verified", + HasTransparencyProof: false), + new DemoFinding( + VulnId: "vuln-002", + FindingId: "22222222-2222-2222-2222-222222222222", + ScanId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + CveId: "CVE-2021-45046", + Title: "Log4j2 Thread Context Message Pattern DoS", + Description: "The Log4Shell fix in 2.15.0 was incomplete in certain non-default configurations.", + Severity: "critical", + CvssScore: 9.0, + CvssVector: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H", + Status: "excepted", + PublishedAt: "2021-12-14T00:00:00Z", + ModifiedAt: "2023-11-06T00:00:00Z", + Components: + [ + new DemoComponent( + Purl: "pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0", + Name: "log4j-core", + Version: "2.15.0", + FixedVersion: "2.17.1", + AssetIds: ["asset-internal-001"]) + ], + References: [], + HasException: true, + ExceptionId: "exc-test-001", + ReachabilityScore: 0.82, + ReachabilityStatus: "unreachable", + EpssScore: 0.66, + KevListed: false, + BlastRadiusAssetCount: 1, + GatingReason: GatingReason.VexNotAffected, + GatingExplanation: "VEX statement from 'vendor-cert' declares not_affected (trust: 90%).", + WouldShowIf: ["Contest the VEX statement", "Lower trust threshold in policy"], + VexStatus: "not_affected", + PolicyVerdict: "pass", + AttestationStatus: "verified", + HasTransparencyProof: true), + new DemoFinding( + VulnId: "vuln-006", + FindingId: "66666666-6666-6666-6666-666666666666", + ScanId: "cccccccc-cccc-cccc-cccc-cccccccccccc", + CveId: "CVE-2022-22965", + Title: "Spring4Shell - Spring Framework RCE", + Description: "A Spring MVC or WebFlux application on JDK 9+ may be vulnerable via data binding.", + Severity: "critical", + CvssScore: 9.8, + CvssVector: null, + Status: "wont_fix", + PublishedAt: "2022-03-31T00:00:00Z", + ModifiedAt: "2024-08-20T00:00:00Z", + Components: + [ + new DemoComponent( + Purl: "pkg:maven/org.springframework/spring-beans@5.3.17", + Name: "spring-beans", + Version: "5.3.17", + FixedVersion: "5.3.18", + AssetIds: ["asset-legacy-001"]) + ], + References: [], + HasException: true, + ExceptionId: "exc-legacy-spring", + ReachabilityScore: 0.49, + ReachabilityStatus: "unknown", + EpssScore: 0.74, + KevListed: false, + BlastRadiusAssetCount: 1, + GatingReason: GatingReason.UserMuted, + GatingExplanation: "This finding has been muted by a user decision.", + WouldShowIf: ["Un-mute the finding in triage settings"], + VexStatus: "under_investigation", + PolicyVerdict: "warn", + AttestationStatus: "unverified", + HasTransparencyProof: false), + ]; + + internal static IReadOnlyList ListVulnerabilities(bool includeReachability) + => Findings.Select(item => item.ToVulnerabilityDto(includeReachability)).ToArray(); + + internal static ScannerVulnerabilityDto? GetVulnerability(string vulnId) + => Findings + .FirstOrDefault(item => string.Equals(item.VulnId, vulnId, StringComparison.OrdinalIgnoreCase)) + ?.ToVulnerabilityDto(includeReachability: true); + + internal static FindingGatingStatusDto? GetGatingStatus(string findingId) + => Findings + .FirstOrDefault(item => string.Equals(item.FindingId, findingId, StringComparison.OrdinalIgnoreCase)) + ?.ToGatingStatus(); + + internal static GatedBucketsSummaryDto? GetScanSummary(string scanId) + { + var matching = Findings + .Where(item => string.Equals(item.ScanId, scanId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + return matching.Length == 0 ? null : BuildSummary(matching); + } + + internal static GatedBucketsSummaryDto? GetArtifactSummary(string artifactId) + { + if (string.IsNullOrWhiteSpace(artifactId)) + { + return null; + } + + var matching = Findings + .Where(item => item.Components.Any(component => component.AssetIds.Contains(artifactId, StringComparer.OrdinalIgnoreCase))) + .ToArray(); + + return matching.Length == 0 ? GatedBucketsSummaryDto.Empty : BuildSummary(matching); + } + + internal static UnifiedEvidenceResponseDto? GetUnifiedEvidence(string findingId) + => Findings + .FirstOrDefault(item => string.Equals(item.FindingId, findingId, StringComparison.OrdinalIgnoreCase)) + ?.ToUnifiedEvidence(); + + private static GatedBucketsSummaryDto BuildSummary(IEnumerable findings) + { + var statuses = findings.Select(item => item.GatingReason).ToArray(); + return new GatedBucketsSummaryDto + { + UnreachableCount = statuses.Count(reason => reason == GatingReason.Unreachable), + PolicyDismissedCount = statuses.Count(reason => reason == GatingReason.PolicyDismissed), + BackportedCount = statuses.Count(reason => reason == GatingReason.Backported), + VexNotAffectedCount = statuses.Count(reason => reason == GatingReason.VexNotAffected), + SupersededCount = statuses.Count(reason => reason == GatingReason.Superseded), + UserMutedCount = statuses.Count(reason => reason == GatingReason.UserMuted), + }; + } + + private sealed record DemoFinding( + string VulnId, + string FindingId, + string ScanId, + string CveId, + string Title, + string Description, + string Severity, + double? CvssScore, + string? CvssVector, + string Status, + string PublishedAt, + string ModifiedAt, + IReadOnlyList Components, + IReadOnlyList References, + bool HasException, + string? ExceptionId, + double ReachabilityScore, + string ReachabilityStatus, + double EpssScore, + bool KevListed, + int BlastRadiusAssetCount, + GatingReason GatingReason, + string? GatingExplanation, + IReadOnlyList? WouldShowIf, + string VexStatus, + string PolicyVerdict, + string AttestationStatus, + bool HasTransparencyProof) + { + public ScannerVulnerabilityDto ToVulnerabilityDto(bool includeReachability) => + new( + VulnId: VulnId, + FindingId: FindingId, + CveId: CveId, + Title: Title, + Description: Description, + Severity: Severity, + CvssScore: CvssScore, + CvssVector: CvssVector, + Status: Status, + PublishedAt: PublishedAt, + ModifiedAt: ModifiedAt, + AffectedComponents: Components + .Select(component => new ScannerAffectedComponentDto( + component.Purl, + component.Name, + component.Version, + component.FixedVersion, + component.AssetIds)) + .ToArray(), + References: References, + HasException: HasException, + ExceptionId: ExceptionId, + ReachabilityScore: includeReachability ? ReachabilityScore : null, + ReachabilityStatus: includeReachability ? ReachabilityStatus : null, + EpssScore: EpssScore, + KevListed: KevListed, + BlastRadiusAssetCount: BlastRadiusAssetCount); + + public FindingGatingStatusDto ToGatingStatus() => + new() + { + GatingReason = GatingReason, + IsHiddenByDefault = GatingReason != GatingReason.None, + SubgraphId = $"subgraph-{FindingId}", + DeltasId = $"delta-{FindingId}", + GatingExplanation = GatingExplanation, + WouldShowIf = WouldShowIf, + }; + + public UnifiedEvidenceResponseDto ToUnifiedEvidence() + { + var component = Components[0]; + return new UnifiedEvidenceResponseDto + { + FindingId = FindingId, + CveId = CveId, + ComponentPurl = component.Purl, + Sbom = new SbomEvidenceDto + { + Format = "CycloneDX", + Version = "1.5", + DocumentUri = $"/security/sbom-lake?artifact={Uri.EscapeDataString(component.Purl)}", + Digest = $"sha256:sbom-{FindingId}", + Component = new SbomComponentDto + { + Purl = component.Purl, + Name = component.Name, + Version = component.Version, + Ecosystem = "demo", + }, + Dependencies = ["pkg:demo/dependency@1.0.0"], + Dependents = ["pkg:oci/stella-ops/asset@sha256:demo"], + }, + Reachability = new ReachabilityEvidenceDto + { + SubgraphId = $"subgraph-{FindingId}", + Status = ReachabilityStatus, + Confidence = ReachabilityScore, + Method = "static", + EntryPoints = + [ + new EntryPointDto + { + Id = $"entry-{FindingId}", + Type = "http", + Name = "/api/orders", + Location = "src/api/orders.ts:42", + Distance = 3, + } + ], + CallChain = new CallChainSummaryDto + { + PathLength = ReachabilityStatus == "reachable" ? 4 : 1, + PathCount = ReachabilityStatus == "reachable" ? 2 : 0, + KeySymbols = ReachabilityStatus == "reachable" + ? ["OrderController.handle", "LoggerFacade.warn", "JndiLookup.lookup"] + : [], + CallGraphUri = $"/security/reachability?findingId={Uri.EscapeDataString(FindingId)}", + }, + GraphUri = $"/security/reachability?findingId={Uri.EscapeDataString(FindingId)}", + }, + VexClaims = + [ + new VexClaimDto + { + StatementId = $"vex-{FindingId}", + Source = "demo-vex", + Status = VexStatus, + Justification = VexStatus == "not_affected" ? "component_not_present" : "requires_context", + ImpactStatement = "Deterministic local setup sample evidence.", + IssuedAt = FixedGeneratedAt, + TrustScore = VexStatus == "not_affected" ? 0.9 : 0.65, + MeetsPolicyThreshold = VexStatus == "not_affected", + DocumentUri = $"/security/advisories-vex?findingId={Uri.EscapeDataString(FindingId)}", + } + ], + Attestations = + [ + new AttestationSummaryDto + { + Id = $"att-{FindingId}", + PredicateType = "https://stellaops.dev/demo/triage-evidence", + SubjectDigest = $"sha256:artifact-{FindingId}", + Signer = "stella-demo-signer", + SignedAt = FixedGeneratedAt, + VerificationStatus = AttestationStatus, + TransparencyLogEntry = HasTransparencyProof ? $"rekor://demo/{FindingId}" : null, + AttestationUri = $"/evidence/attestations/{Uri.EscapeDataString(FindingId)}", + } + ], + Deltas = new DeltaEvidenceDto + { + DeltaId = $"delta-{FindingId}", + PreviousScanId = "previous-demo-scan", + CurrentScanId = ScanId, + ComparedAt = FixedGeneratedAt, + Summary = new DeltaSummaryDto + { + AddedCount = 1, + RemovedCount = 0, + ChangedCount = 1, + IsNew = Status == "open", + StatusChanged = Status == "in_progress", + PreviousStatus = Status == "in_progress" ? "open" : null, + }, + DeltaReportUri = $"/security/findings?findingId={Uri.EscapeDataString(FindingId)}", + }, + Policy = new PolicyEvidenceDto + { + PolicyVersion = "demo-policy-v2", + PolicyDigest = $"sha256:policy-{FindingId}", + Verdict = PolicyVerdict, + RulesFired = + [ + new PolicyRuleFiredDto + { + RuleId = "runtime-risk-budget-v2", + Name = "Runtime risk budget", + Effect = PolicyVerdict, + Reason = GatingExplanation ?? "Finding remains actionable in the current artifact context.", + } + ], + PolicyDocumentUri = "/ops/policy/risk-budget", + }, + Manifests = new ManifestHashesDto + { + ArtifactDigest = $"sha256:artifact-{FindingId}", + ManifestHash = $"sha256:manifest-{FindingId}", + FeedSnapshotHash = $"sha256:feed-{FindingId}", + PolicyHash = $"sha256:policy-{FindingId}", + KnowledgeSnapshotId = $"snapshot-{FindingId}", + }, + Verification = new VerificationStatusDto + { + Status = AttestationStatus == "verified" ? "verified" : "partial", + HashesVerified = true, + AttestationsVerified = AttestationStatus == "verified", + EvidenceComplete = true, + Issues = AttestationStatus == "verified" ? null : ["No verified attestation is available for this demo finding."], + VerifiedAt = FixedGeneratedAt, + }, + ReplayCommand = $"stella triage replay --finding-id {FindingId}", + ShortReplayCommand = $"stella triage replay --snapshot snapshot-{FindingId}", + EvidenceBundleUrl = $"/api/v1/triage/findings/{Uri.EscapeDataString(FindingId)}/evidence/export?format=zip", + GeneratedAt = FixedGeneratedAt, + CacheKey = $"demo-{FindingId}", + }; + } + } + + private sealed record DemoComponent( + string Purl, + string Name, + string Version, + string? FixedVersion, + IReadOnlyList AssetIds); +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs index 85fff870b..8a3e9b751 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs @@ -41,6 +41,12 @@ public sealed class GatingReasonService : IGatingReasonService { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var demoStatus = DeterministicTriageDemoCatalog.GetGatingStatus(findingId); + if (demoStatus is not null) + { + return demoStatus; + } + if (!Guid.TryParse(findingId, out var id)) { _logger.LogWarning("Invalid finding id format: {FindingId}", findingId); @@ -76,14 +82,21 @@ public sealed class GatingReasonService : IGatingReasonService { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var demoStatuses = findingIds + .Select(id => DeterministicTriageDemoCatalog.GetGatingStatus(id)) + .Where(status => status is not null) + .Select(status => status!) + .ToList(); + var validIds = findingIds + .Where(id => DeterministicTriageDemoCatalog.GetGatingStatus(id) is null) .Where(id => Guid.TryParse(id, out _)) .Select(Guid.Parse) .ToList(); if (validIds.Count == 0) { - return Array.Empty(); + return demoStatuses; } var normalizedTenantId = tenantId.Trim().ToLowerInvariant(); @@ -97,8 +110,8 @@ public sealed class GatingReasonService : IGatingReasonService .ToListAsync(cancellationToken) .ConfigureAwait(false); - return findings - .Select(ComputeGatingStatus) + return demoStatuses + .Concat(findings.Select(ComputeGatingStatus)) .ToList(); } @@ -110,6 +123,12 @@ public sealed class GatingReasonService : IGatingReasonService { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var demoSummary = DeterministicTriageDemoCatalog.GetScanSummary(scanId); + if (demoSummary is not null) + { + return demoSummary; + } + if (!Guid.TryParse(scanId, out var id)) { _logger.LogWarning("Invalid scan id format: {ScanId}", scanId); @@ -146,6 +165,49 @@ public sealed class GatingReasonService : IGatingReasonService }; } + /// + public async Task GetArtifactGatedBucketsSummaryAsync( + string tenantId, + string artifactId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var demoSummary = DeterministicTriageDemoCatalog.GetArtifactSummary(artifactId); + if (demoSummary is not null) + { + return demoSummary; + } + + var normalizedTenantId = tenantId.Trim().ToLowerInvariant(); + + var findings = await _dbContext.Findings + .Include(f => f.ReachabilityResults) + .Include(f => f.EffectiveVexRecords) + .Include(f => f.PolicyDecisions) + .AsNoTracking() + .Where(f => f.TenantId == normalizedTenantId && f.AssetLabel == artifactId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (findings.Count == 0) + { + return GatedBucketsSummaryDto.Empty; + } + + var gatingStatuses = findings.Select(ComputeGatingStatus).ToList(); + + return new GatedBucketsSummaryDto + { + UnreachableCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.Unreachable), + PolicyDismissedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.PolicyDismissed), + BackportedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.Backported), + VexNotAffectedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.VexNotAffected), + SupersededCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.Superseded), + UserMutedCount = gatingStatuses.Count(g => g.GatingReason == GatingReason.UserMuted) + }; + } + /// /// Computes the gating status for a finding based on its evidence. /// diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/IGatingReasonService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/IGatingReasonService.cs index 0bbeffdda..801d6a656 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/IGatingReasonService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/IGatingReasonService.cs @@ -48,4 +48,16 @@ public interface IGatingReasonService string tenantId, string scanId, CancellationToken cancellationToken = default); + + /// + /// Computes the gated buckets summary for an artifact workspace. + /// + /// Tenant identifier. + /// Artifact identifier from the workspace route. + /// Cancellation token. + /// Summary of gated buckets for the artifact. + Task GetArtifactGatedBucketsSummaryAsync( + string tenantId, + string artifactId, + CancellationToken cancellationToken = default); } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/UnifiedEvidenceService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/UnifiedEvidenceService.cs index 712282ba4..d8ecad84b 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/UnifiedEvidenceService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/UnifiedEvidenceService.cs @@ -53,6 +53,12 @@ public sealed class UnifiedEvidenceService : IUnifiedEvidenceService ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); options ??= new UnifiedEvidenceOptions(); + var demoEvidence = DeterministicTriageDemoCatalog.GetUnifiedEvidence(findingId); + if (demoEvidence is not null) + { + return demoEvidence; + } + if (!Guid.TryParse(findingId, out var id)) { _logger.LogWarning("Invalid finding id format: {FindingId}", findingId); diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md index 73b14c710..c6206ef0a 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md @@ -21,3 +21,4 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl | SPRINT-20260222-057-SCAN-TEN-11 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: propagated resolved tenant context through SmartDiff/Reachability endpoints into tenant-partitioned repository queries (2026-02-23). | | SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: updated source-run and secret-exception service/endpoints to require tenant-scoped repository lookups for API-backed tenant tables (2026-02-23). | | SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Scanner WebService and replaced selected hardcoded endpoint strings with `_t(...)` keys (en-US/de-DE bundles added). | +| SPRINT-20260311-003-VULNREAD-001 | DONE | `SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md`: restored the documented scanner-backed `/api/v1/vulnerabilities` read contract for the live triage artifact workspace, with targeted controller tests and compose redeploy proof (2026-03-11). | diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/TriageControllerDemoContractsTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/TriageControllerDemoContractsTests.cs new file mode 100644 index 000000000..4f4e3e73f --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/TriageControllerDemoContractsTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Net.Http.Json; +using StellaOps.Scanner.WebService.Contracts; +using StellaOps.TestKit; + +namespace StellaOps.Scanner.WebService.Tests; + +public sealed class TriageControllerDemoContractsTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ArtifactGatedBuckets_ReturnsDeterministicSummaryForArtifactWorkspace() + { + await using var factory = ScannerApplicationFactory.CreateLightweight(); + await factory.InitializeAsync(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync( + "/api/v1/triage/artifacts/asset-web-prod/gated-buckets", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.True(payload!.TotalHiddenCount >= 1); + Assert.True(payload.UnreachableCount >= 1); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task DemoFindingEvidence_ReturnsUnifiedEvidenceWithoutDatabaseBacking() + { + await using var factory = ScannerApplicationFactory.CreateLightweight(); + await factory.InitializeAsync(); + using var client = factory.CreateClient(); + + const string findingId = "11111111-1111-1111-1111-111111111111"; + var response = await client.GetAsync( + $"/api/v1/triage/findings/{findingId}/evidence?includeReplayCommand=true&includeReachability=true&includeVex=true&includeAttestations=true&includeDeltas=true", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.Equal(findingId, payload!.FindingId); + Assert.NotNull(payload.Reachability); + Assert.NotEmpty(payload.Attestations ?? []); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/VulnerabilitiesControllerTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/VulnerabilitiesControllerTests.cs new file mode 100644 index 000000000..7a7090391 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/VulnerabilitiesControllerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using StellaOps.Scanner.WebService.Controllers; +using StellaOps.TestKit; + +namespace StellaOps.Scanner.WebService.Tests; + +public sealed class VulnerabilitiesControllerTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task List_ReturnsDeterministicReachabilityAwarePayload() + { + await using var factory = ScannerApplicationFactory.CreateLightweight(); + await factory.InitializeAsync(); + using var client = factory.CreateClient(); + + var firstResponse = await client.GetAsync("/api/v1/vulnerabilities?includeReachability=true", TestContext.Current.CancellationToken); + var secondResponse = await client.GetAsync("/api/v1/vulnerabilities?includeReachability=true", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + + var first = await firstResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var second = await secondResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Equal(first, second); + + var payload = await firstResponse.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.NotEmpty(payload!.Items); + Assert.All(payload.Items, item => Assert.False(string.IsNullOrWhiteSpace(item.VulnId))); + Assert.Contains(payload.Items, item => item.ReachabilityStatus is not null); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task List_AppliesSeverityAndReachabilityFilters() + { + await using var factory = ScannerApplicationFactory.CreateLightweight(); + await factory.InitializeAsync(); + using var client = factory.CreateClient(); + + var payload = await client.GetFromJsonAsync( + "/api/v1/vulnerabilities?severity=critical&reachability=reachable&includeReachability=true", + TestContext.Current.CancellationToken); + + Assert.NotNull(payload); + Assert.NotEmpty(payload!.Items); + Assert.All(payload.Items, item => + { + Assert.Equal("critical", item.Severity); + Assert.Equal("reachable", item.ReachabilityStatus); + }); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task DetailAndStats_ReturnExpectedContracts() + { + await using var factory = ScannerApplicationFactory.CreateLightweight(); + await factory.InitializeAsync(); + using var client = factory.CreateClient(); + + var detail = await client.GetFromJsonAsync( + "/api/v1/vulnerabilities/vuln-001", + TestContext.Current.CancellationToken); + var stats = await client.GetFromJsonAsync( + "/api/v1/vulnerabilities/status", + TestContext.Current.CancellationToken); + + Assert.NotNull(detail); + Assert.Equal("CVE-2021-44228", detail!.CveId); + Assert.False(string.IsNullOrWhiteSpace(detail.FindingId)); + Assert.NotEmpty(detail.AffectedComponents); + + Assert.NotNull(stats); + Assert.True(stats!.Total >= 1); + Assert.Equal(stats.BySeverity.Values.Sum(), stats.Total); + } +} diff --git a/src/Web/StellaOps.Web/scripts/live-triage-artifacts-scope-compat.mjs b/src/Web/StellaOps.Web/scripts/live-triage-artifacts-scope-compat.mjs new file mode 100644 index 000000000..4179980fc --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-triage-artifacts-scope-compat.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-triage-artifacts-scope-compat.json'); +const authStatePath = path.join(outputDir, 'live-triage-artifacts-scope-compat.state.json'); +const authReportPath = path.join(outputDir, 'live-triage-artifacts-scope-compat.auth.json'); +const routePath = '/triage/artifacts'; + +function scopedUrl(route = routePath) { + return `https://stella-ops.local${route}`; +} + +async function settle(page, timeoutMs = 1_500) { + await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {}); + await page.waitForTimeout(timeoutMs); +} + +async function waitForArtifactsSurface(page) { + await Promise.race([ + page.locator('tbody tr').first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}), + page.getByText('Unable to load artifacts', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}), + page.getByText('No artifacts match the current lane and filters.', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}), + page.waitForTimeout(20_000), + ]); +} + +async function waitForWorkspaceSurface(page) { + await Promise.race([ + page.locator('[data-finding-card]').first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}), + page.getByText('Unable to load findings', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}), + page.getByText('No findings for this artifact.', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}), + page.waitForTimeout(20_000), + ]); +} + +async function assert(condition, message, details = {}) { + if (!condition) { + throw new Error(`${message}${Object.keys(details).length ? ` ${JSON.stringify(details)}` : ''}`); + } +} + +async function run() { + await mkdir(outputDir, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath: authStatePath, + reportPath: authReportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath }); + const page = await context.newPage(); + const consoleErrors = []; + const responseErrors = []; + const requestFailures = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + page.on('response', (response) => { + const url = response.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + if (url.includes('/connect/authorize')) { + return; + } + + if (response.status() >= 400) { + responseErrors.push({ + status: response.status(), + method: response.request().method(), + url, + }); + } + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + const failure = request.failure()?.errorText ?? 'unknown'; + if (failure === 'net::ERR_ABORTED') { + return; + } + + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + requestFailures.push({ + method: request.method(), + url, + error: failure, + }); + }); + + const checks = []; + + try { + await page.goto(scopedUrl(), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await waitForArtifactsSurface(page); + await settle(page); + + const heading = (await page.locator('h1').first().textContent())?.trim() ?? ''; + await assert(heading === 'Artifact workspace', 'Unexpected artifact workspace heading.', { heading }); + checks.push({ name: 'artifacts-heading', ok: true, heading }); + + const loadErrorVisible = await page.getByText('Unable to load artifacts', { exact: true }).isVisible().catch(() => false); + await assert(!loadErrorVisible, 'Artifact workspace still rendered the load error state.'); + checks.push({ name: 'artifacts-no-error-state', ok: true }); + + const initialRowCount = await page.locator('tbody tr').count(); + await assert(initialRowCount > 0, 'Artifact workspace did not render any rows.', { initialRowCount }); + checks.push({ name: 'artifacts-row-count', ok: true, initialRowCount }); + + await page.getByRole('button', { name: 'Needs Review' }).click({ timeout: 10_000 }); + await settle(page); + await page.getByPlaceholder('Search artifacts or environments...').fill('asset-web-prod'); + await settle(page); + + const filteredRowCount = await page.locator('tbody tr').count(); + await assert(filteredRowCount === 1, 'Needs Review lane search did not isolate asset-web-prod.', { filteredRowCount }); + checks.push({ name: 'artifacts-lane-and-search', ok: true, filteredRowCount }); + + await page.getByRole('button', { name: 'Open workspace' }).first().click({ timeout: 10_000 }); + await waitForWorkspaceSurface(page); + await settle(page); + + const detailHeading = (await page.locator('h1').first().textContent())?.trim() ?? ''; + const detailSubtitle = ((await page.locator('.subtitle').first().textContent()) || '').trim().replace(/\s+/g, ' '); + await assert(detailHeading === 'Artifact triage', 'Unexpected workspace heading after opening an artifact.', { + detailHeading, + url: page.url(), + }); + await assert(detailSubtitle.includes('asset-web-prod'), 'Workspace subtitle did not identify the selected artifact.', { + detailSubtitle, + }); + checks.push({ name: 'workspace-route-and-heading', ok: true, detailHeading, detailSubtitle, url: page.url() }); + + const detailErrorVisible = await page.getByText('Unable to load findings', { exact: true }).isVisible().catch(() => false); + await assert(!detailErrorVisible, 'Artifact workspace still rendered the findings error state.'); + const findingCardCount = await page.locator('[data-finding-card]').count(); + await assert(findingCardCount > 0, 'Artifact workspace did not render any finding cards.', { findingCardCount }); + checks.push({ name: 'workspace-finding-cards', ok: true, findingCardCount }); + + await page.getByRole('tab', { name: 'Attestations' }).click({ timeout: 10_000 }); + await settle(page); + await assert(page.url().includes('tab=attestations'), 'Workspace tab action did not update the route state.', { + url: page.url(), + }); + checks.push({ name: 'workspace-tab-action', ok: true, url: page.url() }); + + await page.getByRole('link', { name: /Back to artifacts/i }).click({ timeout: 10_000 }); + await waitForArtifactsSurface(page); + await settle(page); + await assert(page.url().includes('/triage/artifacts'), 'Workspace back navigation did not return to the artifacts route.', { + url: page.url(), + }); + checks.push({ name: 'workspace-back-navigation', ok: true, url: page.url() }); + + const runtimeIssues = { + consoleErrors, + responseErrors, + requestFailures, + }; + const summary = { + checkedAtUtc: new Date().toISOString(), + routePath, + checks, + runtimeIssues, + failedCheckCount: checks.filter((check) => !check.ok).length, + runtimeIssueCount: + runtimeIssues.consoleErrors.length + + runtimeIssues.responseErrors.length + + runtimeIssues.requestFailures.length, + }; + + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + + if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) { + process.exitCode = 1; + } + } catch (error) { + const summary = { + checkedAtUtc: new Date().toISOString(), + routePath, + checks, + error: error instanceof Error ? error.message : String(error), + consoleErrors, + responseErrors, + requestFailures, + }; + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + process.exitCode = 1; + } finally { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + } +} + +await run(); diff --git a/src/Web/StellaOps.Web/src/app/app.config-paths.spec.ts b/src/Web/StellaOps.Web/src/app/app.config-paths.spec.ts new file mode 100644 index 000000000..0909b0ffb --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/app.config-paths.spec.ts @@ -0,0 +1,13 @@ +import { resolveApiRootUrl } from './app.config'; + +describe('app config url helpers', () => { + it('preserves service roots while trimming a trailing slash', () => { + expect(resolveApiRootUrl('/authority/')).toBe('/authority'); + expect(resolveApiRootUrl('https://api.example.local/authority/')).toBe('https://api.example.local/authority'); + }); + + it('returns an empty string when the configured root is blank', () => { + expect(resolveApiRootUrl(undefined)).toBe(''); + expect(resolveApiRootUrl(' ')).toBe(''); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 87eb21bc9..4c2e9deac 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -22,14 +22,18 @@ import { NotifyApiHttpClient, MockNotifyClient, } from './core/api/notify.client'; -import { - EXCEPTION_API, - EXCEPTION_API_BASE_URL, - ExceptionApiHttpClient, - MockExceptionApiService, -} from './core/api/exception.client'; -import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; -import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/vulnerability-http.client'; +import { + EXCEPTION_API, + EXCEPTION_API_BASE_URL, + ExceptionApiHttpClient, + MockExceptionApiService, +} from './core/api/exception.client'; +import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; +import { + VULNERABILITY_API_BASE_URL, + VULNERABILITY_QUERY_API_BASE_URL, + VulnerabilityHttpClient, +} from './core/api/vulnerability-http.client'; import { RISK_API, MockRiskApi } from './core/api/risk.client'; import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; import { AppConfigService } from './core/config/app-config.service'; @@ -262,8 +266,8 @@ import { MockIdentityProviderClient, } from './core/api/identity-provider.client'; -function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { - const normalizedBase = (baseUrl ?? '').trim(); +function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { + const normalizedBase = (baseUrl ?? '').trim(); if (!normalizedBase) { return path; @@ -281,8 +285,19 @@ function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { : normalizedBase; return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`; - } -} + } +} + +export function resolveApiRootUrl(baseUrl: string | undefined): string { + const normalizedBase = (baseUrl ?? '').trim(); + if (!normalizedBase) { + return ''; + } + + return normalizedBase.endsWith('/') + ? normalizedBase.slice(0, -1) + : normalizedBase; +} export const appConfig: ApplicationConfig = { providers: [ @@ -352,21 +367,21 @@ export const appConfig: ApplicationConfig = { })(inject(AuthSessionStore), inject(TenantActivationService)); return initializerFn(); }), - { - provide: RISK_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const authorityBase = config.config.apiBaseUrls.authority; - try { - return new URL('/risk', authorityBase).toString(); - } catch { - const normalized = authorityBase.endsWith('/') - ? authorityBase.slice(0, -1) - : authorityBase; - return `${normalized}/risk`; - } - }, - }, + { + provide: RISK_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const authorityBase = config.config.apiBaseUrls.authority; + try { + return new URL('/risk', authorityBase).toString(); + } catch { + const normalized = authorityBase.endsWith('/') + ? authorityBase.slice(0, -1) + : authorityBase; + return `${normalized}/risk`; + } + }, + }, { provide: AUTH_SERVICE, useExisting: AuthorityAuthAdapterService, @@ -390,21 +405,23 @@ export const appConfig: ApplicationConfig = { provide: RISK_API, useExisting: RiskHttpClient, }, - { - provide: VULNERABILITY_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const authorityBase = config.config.apiBaseUrls.authority; - try { - return new URL('/vuln', authorityBase).toString(); - } catch { - const normalized = authorityBase.endsWith('/') - ? authorityBase.slice(0, -1) - : authorityBase; - return `${normalized}/vuln`; - } - }, - }, + { + provide: VULNERABILITY_QUERY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway + ?? config.config.apiBaseUrls.scanner + ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/v1/vulnerabilities'); + }, + }, + { + provide: VULNERABILITY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + return resolveApiRootUrl(config.config.apiBaseUrls.authority); + }, + }, VulnerabilityHttpClient, MockVulnerabilityApiService, { diff --git a/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.spec.ts index 38ff03063..86cafdbc2 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.spec.ts @@ -3,7 +3,11 @@ import { TestBed } from '@angular/core/testing'; import { AuthSessionStore } from '../auth/auth-session.store'; import { TenantActivationService } from '../auth/tenant-activation.service'; -import { VulnerabilityHttpClient, VULNERABILITY_API_BASE_URL } from './vulnerability-http.client'; +import { + VulnerabilityHttpClient, + VULNERABILITY_API_BASE_URL, + VULNERABILITY_QUERY_API_BASE_URL, +} from './vulnerability-http.client'; import { VulnerabilitiesResponse } from './vulnerability.models'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; @@ -30,6 +34,7 @@ describe('VulnerabilityHttpClient', () => { imports: [], providers: [ VulnerabilityHttpClient, + { provide: VULNERABILITY_QUERY_API_BASE_URL, useValue: 'https://query.example.local/api/v1/vulnerabilities' }, { provide: VULNERABILITY_API_BASE_URL, useValue: 'https://api.example.local' }, { provide: AuthSessionStore, useClass: MockAuthSessionStore }, { provide: TenantActivationService, useValue: tenantServiceStub }, @@ -51,7 +56,7 @@ describe('VulnerabilityHttpClient', () => { expect(resp.page).toBe(1); }); - const req = httpMock.expectOne('https://api.example.local/vuln?page=1&pageSize=5'); + const req = httpMock.expectOne('https://query.example.local/api/v1/vulnerabilities?page=1&pageSize=5'); expect(req.request.headers.get('X-Stella-Ops-Tenant')).toBe('tenant-dev'); expect(req.request.headers.has('X-Stella-Ops-Trace-Id')).toBeTrue(); req.flush(stub); @@ -60,7 +65,7 @@ describe('VulnerabilityHttpClient', () => { it('adds project header when provided', () => { client.listVulnerabilities({ page: 1, projectId: 'proj-ops' }).subscribe(); - const req = httpMock.expectOne('https://api.example.local/vuln?page=1'); + const req = httpMock.expectOne('https://query.example.local/api/v1/vulnerabilities?page=1'); expect(req.request.headers.get('X-Stella-Ops-Project')).toBe('proj-ops'); req.flush({ items: [], total: 0, page: 1, pageSize: 20 }); }); diff --git a/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts index 79cfc3c5c..52f8abba6 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts @@ -20,6 +20,7 @@ import { StellaOpsHeaders } from '../http/stella-ops-headers'; import { VulnerabilityApi } from './vulnerability.client'; export const VULNERABILITY_API_BASE_URL = new InjectionToken('VULNERABILITY_API_BASE_URL'); +export const VULNERABILITY_QUERY_API_BASE_URL = new InjectionToken('VULNERABILITY_QUERY_API_BASE_URL'); /** * HTTP client for vulnerability API with tenant scoping, RBAC/ABAC, and request logging. @@ -36,7 +37,8 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { constructor( private readonly http: HttpClient, private readonly authSession: AuthSessionStore, - @Inject(VULNERABILITY_API_BASE_URL) private readonly baseUrl: string + @Inject(VULNERABILITY_QUERY_API_BASE_URL) private readonly queryBaseUrl: string, + @Inject(VULNERABILITY_API_BASE_URL) private readonly legacyBaseUrl: string ) {} listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable { @@ -62,7 +64,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { if (options?.includeReachability) params = params.set('includeReachability', 'true'); return this.http - .get(`${this.baseUrl}/vuln`, { headers, params, observe: 'response' }) + .get(this.queryBaseUrl, { headers, params, observe: 'response' }) .pipe( map((resp: HttpResponse) => ({ ...resp.body!, @@ -77,7 +79,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { tenantId: tenant, projectId: options?.projectId, operation: 'listVulnerabilities', - path: '/vuln', + path: '/api/v1/vulnerabilities', method: 'GET', timestamp: new Date().toISOString(), durationMs: Date.now() - startTime, @@ -90,7 +92,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { tenantId: tenant, projectId: options?.projectId, operation: 'listVulnerabilities', - path: '/vuln', + path: '/api/v1/vulnerabilities', method: 'GET', timestamp: new Date().toISOString(), durationMs: Date.now() - startTime, @@ -113,10 +115,10 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { } const headers = this.buildHeaders(tenant, options?.projectId, traceId, requestId); - const path = `/vuln/${encodeURIComponent(vulnId)}`; + const path = `${this.queryBaseUrl}/${encodeURIComponent(vulnId)}`; return this.http - .get(`${this.baseUrl}${path}`, { headers, observe: 'response' }) + .get(path, { headers, observe: 'response' }) .pipe( map((resp: HttpResponse) => ({ ...resp.body!, @@ -166,7 +168,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { const headers = this.buildHeaders(tenant, options?.projectId, traceId, requestId); return this.http - .get(`${this.baseUrl}/vuln/status`, { headers }) + .get(`${this.queryBaseUrl}/status`, { headers }) .pipe( map((stats) => ({ ...stats, traceId })), tap(() => this.logRequest({ @@ -175,7 +177,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { tenantId: tenant, projectId: options?.projectId, operation: 'getStats', - path: '/vuln/status', + path: '/api/v1/vulnerabilities/status', method: 'GET', timestamp: new Date().toISOString(), durationMs: Date.now() - startTime, @@ -188,7 +190,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { tenantId: tenant, projectId: options?.projectId, operation: 'getStats', - path: '/vuln/status', + path: '/api/v1/vulnerabilities/status', method: 'GET', timestamp: new Date().toISOString(), durationMs: Date.now() - startTime, @@ -219,7 +221,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { const path = `/ledger/findings/${encodeURIComponent(request.findingId)}/actions`; return this.http - .post(`${this.baseUrl}${path}`, request, { headers, observe: 'response' }) + .post(`${this.legacyBaseUrl}${path}`, request, { headers, observe: 'response' }) .pipe( map((resp: HttpResponse) => ({ ...resp.body!, @@ -273,7 +275,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { const path = '/vuln/export'; return this.http - .post(`${this.baseUrl}${path}`, request, { headers }) + .post(`${this.legacyBaseUrl}${path}`, request, { headers }) .pipe( map((resp) => ({ ...resp, traceId })), tap(() => this.logRequest({ @@ -321,7 +323,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { const path = `/vuln/export/${encodeURIComponent(exportId)}`; return this.http - .get(`${this.baseUrl}${path}`, { headers }) + .get(`${this.legacyBaseUrl}${path}`, { headers }) .pipe( map((resp) => ({ ...resp, traceId })), tap(() => this.logRequest({ @@ -427,4 +429,3 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { } } - diff --git a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts index 784171b09..80ac98c60 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts @@ -41,6 +41,7 @@ export const VULNERABILITY_API = new InjectionToken('VULNERABI const MOCK_VULNERABILITIES: Vulnerability[] = [ { vulnId: 'vuln-001', + findingId: '11111111-1111-1111-1111-111111111111', cveId: 'CVE-2021-44228', title: 'Log4Shell - Remote Code Execution in Apache Log4j', description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.', @@ -67,6 +68,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-002', + findingId: '22222222-2222-2222-2222-222222222222', cveId: 'CVE-2021-45046', title: 'Log4j2 Thread Context Message Pattern DoS', description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.', @@ -90,6 +92,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-003', + findingId: '33333333-3333-3333-3333-333333333333', cveId: 'CVE-2023-44487', title: 'HTTP/2 Rapid Reset Attack', description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.', @@ -119,6 +122,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-004', + findingId: '44444444-4444-4444-4444-444444444444', cveId: 'CVE-2024-21626', title: 'runc container escape vulnerability', description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.', @@ -141,6 +145,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-005', + findingId: '55555555-5555-5555-5555-555555555555', cveId: 'CVE-2023-38545', title: 'curl SOCKS5 heap buffer overflow', description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.', @@ -163,6 +168,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-006', + findingId: '66666666-6666-6666-6666-666666666666', cveId: 'CVE-2022-22965', title: 'Spring4Shell - Spring Framework RCE', description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.', @@ -185,6 +191,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-007', + findingId: '77777777-7777-7777-7777-777777777777', cveId: 'CVE-2023-45853', title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64', description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.', @@ -205,6 +212,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-008', + findingId: '88888888-8888-8888-8888-888888888888', cveId: 'CVE-2024-0567', title: 'GnuTLS certificate verification bypass', description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.', @@ -225,6 +233,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-009', + findingId: '99999999-9999-9999-9999-999999999999', cveId: 'CVE-2023-5363', title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers', description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.', @@ -245,6 +254,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [ }, { vulnId: 'vuln-010', + findingId: 'aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa', cveId: 'CVE-2024-24790', title: 'Go net/netip ParseAddr stack exhaustion', description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.', diff --git a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts index 756b6495e..f2a2eb65d 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts @@ -13,6 +13,7 @@ export type VulnActorType = 'user' | 'service' | 'automation'; export interface Vulnerability { readonly vulnId: string; + readonly findingId?: string; readonly cveId: string; readonly title: string; readonly description?: string; diff --git a/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.spec.ts b/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.spec.ts new file mode 100644 index 000000000..e2ec5a712 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; + +import type { AuthSession } from './auth-session.model'; +import { AuthSessionStore } from './auth-session.store'; +import { TenantActivationService } from './tenant-activation.service'; +import { ConsoleSessionStore } from '../console/console-session.store'; + +function buildSession(scopes: readonly string[]): AuthSession { + const now = Date.now(); + return { + tokens: { + accessToken: 'token', + tokenType: 'Bearer', + scope: scopes.join(' '), + expiresAtEpochMs: now + 60_000, + }, + identity: { + subject: 'user-1', + roles: ['admin'], + }, + dpopKeyThumbprint: 'thumbprint', + issuedAtEpochMs: now, + tenantId: 'demo-prod', + scopes: [...scopes], + audiences: [], + authenticationTimeEpochMs: now, + freshAuthActive: false, + freshAuthExpiresAtEpochMs: null, + }; +} + +describe('TenantActivationService', () => { + let service: TenantActivationService; + let authStore: AuthSessionStore; + + beforeEach(() => { + sessionStorage.clear(); + + TestBed.configureTestingModule({ + providers: [TenantActivationService, AuthSessionStore, ConsoleSessionStore], + }); + + service = TestBed.inject(TenantActivationService); + authStore = TestBed.inject(AuthSessionStore); + }); + + afterEach(() => { + authStore.clear(); + sessionStorage.clear(); + }); + + it('accepts vuln:view for legacy vuln:read checks', () => { + authStore.setSession(buildSession(['vuln:view'])); + + expect(service.authorize('vulnerability', 'read', ['vuln:read'])).toBeTrue(); + }); + + it('accepts vuln:audit for legacy vuln:export checks', () => { + authStore.setSession(buildSession(['vuln:audit'])); + + expect(service.authorize('vulnerability', 'export', ['vuln:export'])).toBeTrue(); + }); + + it('accepts legacy vuln:write for modern vuln:operate checks', () => { + authStore.setSession(buildSession(['vuln:write'])); + + expect(service.authorize('vulnerability', 'operate', ['vuln:operate'])).toBeTrue(); + }); + + it('does not let vuln:investigate satisfy vuln:operate', () => { + authStore.setSession(buildSession(['vuln:investigate'])); + + expect(service.authorize('vulnerability', 'operate', ['vuln:operate'])).toBeFalse(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts b/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts index 99be4d39f..5648040f8 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts @@ -115,6 +115,16 @@ export interface JwtClaims { auth_time?: number; } +const SCOPE_COMPATIBILITY_ALIASES: Readonly> = { + 'vuln:read': ['vuln:view'], + 'vuln:view': ['vuln:read'], + 'vuln:write': ['vuln:investigate', 'vuln:operate'], + 'vuln:investigate': ['vuln:write'], + 'vuln:operate': ['vuln:write'], + 'vuln:export': ['vuln:audit'], + 'vuln:audit': ['vuln:export'], +}; + /** * Service for tenant activation, JWT verification, scope matching, and decision audit. * Implements WEB-TEN-47-001. @@ -484,6 +494,11 @@ export class TenantActivationService { return true; } + const compatibleScopes = SCOPE_COMPATIBILITY_ALIASES[required]; + if (compatibleScopes?.some((scope) => granted.has(scope))) { + return true; + } + // Hierarchical match: admin includes write includes read const [resource, permission] = required.split(':'); if (permission === 'read') { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/services/gating.service.ts b/src/Web/StellaOps.Web/src/app/features/triage/services/gating.service.ts index 59feb2f00..25e60be04 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/services/gating.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/services/gating.service.ts @@ -62,6 +62,19 @@ export class GatingService { ); } + /** + * Get gated buckets summary for an artifact workspace. + */ + getArtifactGatedBucketsSummary(artifactId: string): Observable { + return this.http.get(`${this.baseUrl}/artifacts/${artifactId}/gated-buckets`) + .pipe( + catchError(err => { + console.error(`Failed to get gated buckets for artifact ${artifactId}:`, err); + return of(null); + }) + ); + } + /** * Get unified evidence for a finding. */ diff --git a/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.spec.ts index 73520cc24..218c1c1c6 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.spec.ts @@ -1,25 +1,27 @@ -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; +import { TelemetryClient } from '../../../core/telemetry/telemetry.client'; import { EvidenceBitset } from '../models/evidence.model'; import { TtfsTelemetryService } from './ttfs-telemetry.service'; describe('TtfsTelemetryService', () => { let service: TtfsTelemetryService; - let httpMock: HttpTestingController; + let telemetry: jasmine.SpyObj; beforeEach(() => { + telemetry = jasmine.createSpyObj('TelemetryClient', ['emit']) as jasmine.SpyObj; + TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], + providers: [ + TtfsTelemetryService, + { provide: TelemetryClient, useValue: telemetry }, + ], }); service = TestBed.inject(TtfsTelemetryService); - httpMock = TestBed.inject(HttpTestingController); }); - afterEach(() => httpMock.verify()); - - it('flushes batched events on decision and clears timing state', fakeAsync(() => { + it('emits TTFS lifecycle and budget violations through the shared telemetry client', () => { const times = [0, 300, 600, 1400, 1500, 1700, 2000]; spyOn(performance, 'now').and.callFake(() => times.shift() ?? 0); @@ -31,47 +33,50 @@ describe('TtfsTelemetryService', () => { service.recordInteraction('alert-1', 'click'); service.recordDecision('alert-1', 'accepted'); - const req = httpMock.expectOne('/api/v1/telemetry/ttfs'); - expect(req.request.method).toBe('POST'); - - const body = req.request.body as { events: Array> }; - expect(Array.isArray(body.events)).toBeTrue(); - - const eventTypes = body.events.map((e) => e['event_type']); + const eventTypes = telemetry.emit.calls.allArgs().map(([type]) => type as string); expect(eventTypes).toContain('ttfs.start'); expect(eventTypes).toContain('ttfs.skeleton'); expect(eventTypes).toContain('ttfs.first_evidence'); expect(eventTypes).toContain('ttfs.full_evidence'); + expect(eventTypes).toContain('triage.interaction'); expect(eventTypes).toContain('decision.recorded'); + expect(eventTypes.filter((type) => type === 'budget.violation').length).toBe(2); - expect(body.events.filter((e) => e['event_type'] === 'budget.violation').length).toBe(2); + const firstEvidencePayload = telemetry.emit.calls + .allArgs() + .find(([type]) => type === 'ttfs.first_evidence')?.[1] as Record; + expect(firstEvidencePayload['alert_id']).toBe('alert-1'); + expect(firstEvidencePayload['evidence_type']).toBe('reachability'); + expect(firstEvidencePayload['duration_ms']).toBe(600); - const firstEvidence = body.events.find((e) => e['event_type'] === 'ttfs.first_evidence') as Record; - expect(firstEvidence['evidence_type']).toBe('reachability'); - - const decision = body.events.find((e) => e['event_type'] === 'decision.recorded') as Record; - expect(decision['decision_status']).toBe('accepted'); - expect(decision['click_count']).toBe(2); - - req.flush({}); - tick(); + const decisionPayload = telemetry.emit.calls + .allArgs() + .find(([type]) => type === 'decision.recorded')?.[1] as Record; + expect(decisionPayload['decision_status']).toBe('accepted'); + expect(decisionPayload['click_count']).toBe(2); expect(service.getTimings('alert-1')).toBeUndefined(); - })); + }); - it('flushes queued events after the timeout', fakeAsync(() => { - spyOn(performance, 'now').and.returnValue(0); + it('records a clicks-to-closure budget violation when the interaction budget is exceeded', () => { + const times = [0, 100, 101, 102, 103, 104, 105, 106, 200]; + spyOn(performance, 'now').and.callFake(() => times.shift() ?? 0); - service.startTracking('alert-1', new Date('2025-12-15T00:00:00.000Z')); + service.startTracking('alert-2', new Date('2025-12-15T00:00:00.000Z')); + Array.from({ length: 7 }).forEach(() => service.recordInteraction('alert-2', 'click')); + service.recordDecision('alert-2', 'accepted'); - tick(4999); - httpMock.expectNone('/api/v1/telemetry/ttfs'); + const budgetViolationPayload = telemetry.emit.calls + .allArgs() + .find( + ([type, payload]) => + type === 'budget.violation' + && (payload as Record)['phase'] === 'clicks_to_closure', + )?.[1] as Record; - tick(1); - const req = httpMock.expectOne('/api/v1/telemetry/ttfs'); - expect((req.request.body as { events: unknown[] }).events.length).toBe(1); - req.flush({}); - tick(); - })); + expect(budgetViolationPayload['alert_id']).toBe('alert-2'); + expect(budgetViolationPayload['duration_ms']).toBe(7); + expect(budgetViolationPayload['budget']).toBe(6); + expect(service.getClickCount('alert-2')).toBe(0); + }); }); - diff --git a/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts b/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts index 4175e6ff8..b0c5afb53 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts @@ -1,5 +1,6 @@ -import { DestroyRef, Injectable, NgZone, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { DestroyRef, Injectable, inject } from '@angular/core'; + +import { TelemetryClient } from '../../../core/telemetry/telemetry.client'; import { EvidenceBitset } from '../models/evidence.model'; /** @@ -17,23 +18,6 @@ export interface TtfsTimings { evidenceBitset: number; } -/** - * TTFS event for backend ingestion. - */ -interface TtfsEvent { - event_type: string; - alert_id: string; - duration_ms: number; - evidence_type?: string; - completeness_score?: number; - click_count?: number; - decision_status?: string; - phase?: string; - budget?: number; - evidence_bitset?: number; - timestamp: string; -} - /** * Performance budgets in milliseconds. */ @@ -44,26 +28,14 @@ const BUDGETS = { clicksToClosure: 6, } as const; -/** - * Service for tracking Time-to-First-Signal (TTFS) telemetry. - * Measures time from alert creation to first evidence render. - */ @Injectable({ providedIn: 'root' }) export class TtfsTelemetryService { - private readonly http = inject(HttpClient); - private readonly zone = inject(NgZone); + private readonly telemetry = inject(TelemetryClient); private readonly destroyRef = inject(DestroyRef); private readonly activeTimings = new Map(); - private readonly pendingEvents: TtfsEvent[] = []; - private flushTimeout: ReturnType | null = null; constructor() { this.destroyRef.onDestroy(() => { - if (this.flushTimeout) { - clearTimeout(this.flushTimeout); - this.flushTimeout = null; - } - this.pendingEvents.length = 0; this.activeTimings.clear(); }); } @@ -82,11 +54,8 @@ export class TtfsTelemetryService { this.activeTimings.set(alertId, timing); - this.queueEvent({ - event_type: 'ttfs.start', - alert_id: alertId, + this.emit('ttfs.start', alertId, { duration_ms: 0, - timestamp: new Date().toISOString(), }); } @@ -100,14 +69,10 @@ export class TtfsTelemetryService { timing.skeletonRenderedAt = performance.now(); const duration = timing.skeletonRenderedAt - timing.ttfsStartAt; - this.queueEvent({ - event_type: 'ttfs.skeleton', - alert_id: alertId, + this.emit('ttfs.skeleton', alertId, { duration_ms: duration, - timestamp: new Date().toISOString(), }); - // Check against budget if (duration > BUDGETS.skeleton) { this.recordBudgetViolation(alertId, 'skeleton', duration, BUDGETS.skeleton); } @@ -123,15 +88,11 @@ export class TtfsTelemetryService { timing.firstEvidenceAt = performance.now(); const duration = timing.firstEvidenceAt - timing.ttfsStartAt; - this.queueEvent({ - event_type: 'ttfs.first_evidence', - alert_id: alertId, + this.emit('ttfs.first_evidence', alertId, { duration_ms: duration, evidence_type: evidenceType, - timestamp: new Date().toISOString(), }); - // Check against budget if (duration > BUDGETS.firstEvidence) { this.recordBudgetViolation(alertId, 'first_evidence', duration, BUDGETS.firstEvidence); } @@ -149,16 +110,12 @@ export class TtfsTelemetryService { const duration = timing.fullEvidenceAt - timing.ttfsStartAt; - this.queueEvent({ - event_type: 'ttfs.full_evidence', - alert_id: alertId, + this.emit('ttfs.full_evidence', alertId, { duration_ms: duration, completeness_score: bitset.completenessScore, evidence_bitset: bitset.value, - timestamp: new Date().toISOString(), }); - // Check against budget if (duration > BUDGETS.fullEvidence) { this.recordBudgetViolation(alertId, 'full_evidence', duration, BUDGETS.fullEvidence); } @@ -173,13 +130,10 @@ export class TtfsTelemetryService { timing.clickCount++; - this.queueEvent({ - event_type: 'triage.interaction', - alert_id: alertId, + this.emit('triage.interaction', alertId, { duration_ms: performance.now() - timing.ttfsStartAt, evidence_type: interactionType, click_count: timing.clickCount, - timestamp: new Date().toISOString(), }); } @@ -193,27 +147,19 @@ export class TtfsTelemetryService { timing.decisionRecordedAt = performance.now(); const totalDuration = timing.decisionRecordedAt - timing.ttfsStartAt; - this.queueEvent({ - event_type: 'decision.recorded', - alert_id: alertId, + this.emit('decision.recorded', alertId, { duration_ms: totalDuration, click_count: timing.clickCount, decision_status: decisionStatus, evidence_bitset: timing.evidenceBitset, completeness_score: new EvidenceBitset(timing.evidenceBitset).completenessScore, - timestamp: new Date().toISOString(), }); - // Check clicks-to-closure budget if (timing.clickCount > BUDGETS.clicksToClosure) { this.recordBudgetViolation(alertId, 'clicks_to_closure', timing.clickCount, BUDGETS.clicksToClosure); } - // Cleanup this.activeTimings.delete(alertId); - - // Flush events after decision - this.flushEvents(); } /** @@ -238,53 +184,18 @@ export class TtfsTelemetryService { } private recordBudgetViolation(alertId: string, phase: string, actual: number, budget: number): void { - this.queueEvent({ - event_type: 'budget.violation', - alert_id: alertId, + this.emit('budget.violation', alertId, { duration_ms: actual, phase, budget, - timestamp: new Date().toISOString(), }); } - private queueEvent(event: TtfsEvent): void { - this.pendingEvents.push(event); - - // Schedule flush if not already scheduled - if (!this.flushTimeout) { - this.zone.runOutsideAngular(() => { - this.flushTimeout = setTimeout(() => { - this.zone.run(() => this.flushEvents()); - }, 5000); - }); - } - - // Flush immediately if we have too many events - if (this.pendingEvents.length >= 20) { - this.flushEvents(); - } - } - - private flushEvents(): void { - if (this.flushTimeout) { - clearTimeout(this.flushTimeout); - this.flushTimeout = null; - } - - if (this.pendingEvents.length === 0) return; - - const events = [...this.pendingEvents]; - this.pendingEvents.length = 0; - - // Send to backend - this.http - .post('/api/v1/telemetry/ttfs', { events }) - .subscribe({ - error: (err) => { - // Log but don't fail - telemetry should be non-blocking - console.warn('Failed to send TTFS telemetry:', err); - }, - }); + private emit(eventType: string, alertId: string, payload: Record): void { + this.telemetry.emit(eventType, { + alert_id: alertId, + timestamp: new Date().toISOString(), + ...payload, + }); } } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts index f300c4680..a96900107 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.spec.ts @@ -1,6 +1,6 @@ -import { ComponentFixture, TestBed, fakeAsync, flush, flushMicrotasks } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ActivatedRoute, provideRouter } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client'; @@ -16,19 +16,28 @@ describe('TriageWorkspaceComponent', () => { let vexApi: jasmine.SpyObj; let gatingService: jasmine.SpyObj; + async function initializeComponent(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture.componentInstance; + } + beforeEach(async () => { - vulnApi = jasmine.createSpyObj('VulnerabilityApi', ['listVulnerabilities']); - vexApi = jasmine.createSpyObj('VexDecisionsApi', ['listDecisions']); - gatingService = jasmine.createSpyObj('GatingService', [ + vulnApi = jasmine.createSpyObj('VulnerabilityApi', ['listVulnerabilities']) as jasmine.SpyObj; + vexApi = jasmine.createSpyObj('VexDecisionsApi', ['listDecisions']) as jasmine.SpyObj; + gatingService = jasmine.createSpyObj('GatingService', [ + 'getArtifactGatedBucketsSummary', 'getGatedBucketsSummary', 'getGatingStatus', 'getUnifiedEvidence', 'getReplayCommand', - ]); + ]) as jasmine.SpyObj; const vulns: Vulnerability[] = [ { vulnId: 'v-1', + findingId: '11111111-1111-1111-1111-111111111111', cveId: 'CVE-2024-0001', title: 'Test', severity: 'high', @@ -41,6 +50,7 @@ describe('TriageWorkspaceComponent', () => { }, { vulnId: 'v-2', + findingId: '22222222-2222-2222-2222-222222222222', cveId: 'CVE-2024-0002', title: 'Second', severity: 'high', @@ -53,6 +63,7 @@ describe('TriageWorkspaceComponent', () => { }, { vulnId: 'v-3', + findingId: '33333333-3333-3333-3333-333333333333', cveId: 'CVE-2024-0003', title: 'Other asset', severity: 'high', @@ -65,6 +76,7 @@ describe('TriageWorkspaceComponent', () => { vulnApi.listVulnerabilities.and.returnValue(of({ items: vulns, total: vulns.length })); vexApi.listDecisions.and.returnValue(of({ items: [], count: 0, continuationToken: null })); + gatingService.getArtifactGatedBucketsSummary.and.returnValue(of(null)); gatingService.getGatedBucketsSummary.and.returnValue(of(null)); gatingService.getGatingStatus.and.returnValue(of(null)); gatingService.getReplayCommand.and.returnValue(of(null)); @@ -77,7 +89,16 @@ describe('TriageWorkspaceComponent', () => { { provide: VULNERABILITY_API, useValue: vulnApi }, { provide: VEX_DECISIONS_API, useValue: vexApi }, { provide: GatingService, useValue: gatingService }, - { provide: ActivatedRoute, useValue: { snapshot: { paramMap: new Map([['artifactId', 'asset-web-prod']]) } } }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ artifactId: 'asset-web-prod' }), + queryParamMap: convertToParamMap({}), + }, + queryParamMap: of(convertToParamMap({})), + }, + }, ], }).compileComponents(); @@ -88,20 +109,14 @@ describe('TriageWorkspaceComponent', () => { fixture?.destroy(); }); - it('filters findings by artifactId', fakeAsync(() => { - fixture.detectChanges(); - flushMicrotasks(); - - const component = fixture.componentInstance; + it('filters findings by artifactId', async () => { + const component = await initializeComponent(); expect(component.findings().length).toBe(2); expect(component.findings().map((f) => f.vuln.vulnId)).toEqual(['v-1', 'v-2']); - })); + }); - it('toggles deterministic sort with S', fakeAsync(() => { - fixture.detectChanges(); - flushMicrotasks(); - - const component = fixture.componentInstance; + it('toggles deterministic sort with S', async () => { + const component = await initializeComponent(); expect(component.findingsSort()).toBe('default'); document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true })); @@ -109,15 +124,10 @@ describe('TriageWorkspaceComponent', () => { 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; + it('toggles keyboard help with ?', async () => { + const component = await initializeComponent(); expect(component.showKeyboardHelp()).toBeFalse(); document.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true })); @@ -125,62 +135,58 @@ describe('TriageWorkspaceComponent', () => { 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; + it('selects next finding with ArrowDown', async () => { + const component = await initializeComponent(); expect(component.selectedVulnId()).toBe('v-1'); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); expect(component.selectedVulnId()).toBe('v-2'); + }); - flush(); - })); - - it('switches to reachability tab with /', fakeAsync(() => { - fixture.detectChanges(); - flushMicrotasks(); - - const component = fixture.componentInstance; + it('switches to reachability tab with /', async () => { + const component = await initializeComponent(); expect(component.activeTab()).toBe('evidence'); document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true })); expect(component.activeTab()).toBe('reachability'); + }); - flush(); - })); - - it('routes provenance pill to attestations tab', fakeAsync(() => { - fixture.detectChanges(); - flushMicrotasks(); - - const component = fixture.componentInstance; + it('routes provenance pill to attestations tab', async () => { + const component = await initializeComponent(); component.setTab('evidence'); component.onEvidencePillClick('provenance'); expect(component.activeTab()).toBe('attestations'); - flush(); - })); + }); - it('reports quick-verify unavailable when DSSE/Rekor proofs are missing', fakeAsync(() => { - fixture.detectChanges(); - flushMicrotasks(); - - const component = fixture.componentInstance; + it('reports quick-verify unavailable when DSSE/Rekor proofs are missing', async () => { + const component = await initializeComponent(); component.onQuickVerifyClick(); - flushMicrotasks(); + await fixture.whenStable(); expect(component.quickVerification()?.state).toBe('unavailable'); - expect(component.quickVerification()?.message).toContain('No DSSE signature or Rekor inclusion proof'); - flush(); - })); + expect(component.quickVerification()?.message).toContain('Signed provenance attestation is not yet verified'); + }); - it('marks quick-verify as verified when attestation and transparency evidence are present', fakeAsync(() => { + it('loads artifact gated buckets using the artifact route contract', async () => { + await initializeComponent(); + expect(gatingService.getArtifactGatedBucketsSummary).toHaveBeenCalledWith('asset-web-prod'); + }); + + it('uses canonical findingId when loading unified evidence', async () => { + const component = await initializeComponent(); + component.selectFinding('v-1'); + await fixture.whenStable(); + + expect(gatingService.getUnifiedEvidence).toHaveBeenCalledWith( + '11111111-1111-1111-1111-111111111111', + jasmine.any(Object), + ); + }); + + it('marks quick-verify as verified when attestation and transparency evidence are present', async () => { const verifiedEvidence: UnifiedEvidenceResponse = { findingId: 'v-1', cveId: 'CVE-2024-0001', @@ -213,15 +219,11 @@ describe('TriageWorkspaceComponent', () => { gatingService.getUnifiedEvidence.and.returnValue(of(verifiedEvidence)); - fixture.detectChanges(); - flushMicrotasks(); - - const component = fixture.componentInstance; + const component = await initializeComponent(); component.onQuickVerifyClick(); - flushMicrotasks(); + await fixture.whenStable(); expect(component.quickVerification()?.state).toBe('verified'); expect(component.quickVerification()?.message).toContain('passed'); - flush(); - })); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts index 8bece2405..2409bc0a0 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts @@ -587,8 +587,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { this.gatingLoading.set(true); this.gatingError.set(null); try { - // Use artifactId as scanId for now; adjust when scan context is available - const resp = await firstValueFrom(this.gatingService.getGatedBucketsSummary(artifactId)); + const resp = await firstValueFrom(this.gatingService.getArtifactGatedBucketsSummary(artifactId)); this.gatedBuckets.set(resp); } catch (err) { // Non-fatal: workspace should still render without gated buckets @@ -619,7 +618,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { this.gatingLoading.set(true); this.gatingError.set(null); try { - const status = await firstValueFrom(this.gatingService.getGatingStatus(findingId)); + const status = await firstValueFrom(this.gatingService.getGatingStatus(this.resolveApiFindingId(findingId))); this.gatingExplainerFinding.set(status); } catch (err) { this.gatingExplainerFinding.set(null); @@ -637,10 +636,11 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { /** Load unified evidence for selected finding. */ async loadUnifiedEvidence(findingId: string): Promise { + const apiFindingId = this.resolveApiFindingId(findingId); this.evidenceLoading.set(true); this.evidenceError.set(null); try { - const evidence = await firstValueFrom(this.gatingService.getUnifiedEvidence(findingId, { + const evidence = await firstValueFrom(this.gatingService.getUnifiedEvidence(apiFindingId, { includeReplayCommand: true, includeDeltas: true, includeReachability: true, @@ -652,9 +652,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { this.unifiedEvidenceByFinding.update((state) => ({ ...state, [findingId]: evidence, + [apiFindingId]: evidence, })); } - if (this.selectedVulnId() === findingId) { + if (this.selectedVulnId() === findingId || this.resolveApiFindingId(this.selectedVulnId()) === apiFindingId) { this.currentEvidence.set(this.selectedEvidenceBundle()); } } catch (err) { @@ -1511,7 +1512,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { } private async refreshReplayCommand(findingId: string): Promise { - const replay = await firstValueFrom(this.gatingService.getReplayCommand(findingId, { + const apiFindingId = this.resolveApiFindingId(findingId); + const replay = await firstValueFrom(this.gatingService.getReplayCommand(apiFindingId, { shells: ['bash', 'powershell'], includeOffline: true, generateBundle: true, @@ -1530,8 +1532,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { generatedAt: replay.generatedAt, }; - this.unifiedEvidenceByFinding.update((state) => ({ ...state, [findingId]: updated })); - if (this.unifiedEvidence()?.findingId === findingId) { + this.unifiedEvidenceByFinding.update((state) => ({ ...state, [findingId]: updated, [apiFindingId]: updated })); + if (this.unifiedEvidence()?.findingId === apiFindingId || this.unifiedEvidence()?.findingId === findingId) { this.unifiedEvidence.set(updated); } } @@ -1554,6 +1556,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { if (requestedFindingId) { const requested = this.findings().find( (finding) => + finding.vuln.findingId === requestedFindingId || finding.vuln.vulnId === requestedFindingId || finding.vuln.cveId === requestedFindingId ); @@ -1594,4 +1597,17 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { }) ); } + + private resolveApiFindingId(findingId: string | null | undefined): string { + if (!findingId) { + return ''; + } + + const finding = this.findings().find((item) => + item.vuln.vulnId === findingId + || item.vuln.findingId === findingId + || item.vuln.cveId === findingId); + + return finding?.vuln.findingId ?? findingId; + } } diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 86efc2ca7..1fc9e46a5 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -3,8 +3,12 @@ "include": [], "files": [ "src/test-setup.ts", + "src/app/app.config-paths.spec.ts", + "src/app/types/monaco-workers.d.ts", "src/app/core/api/first-signal.client.spec.ts", + "src/app/core/api/vulnerability-http.client.spec.ts", "src/app/core/api/watchlist.client.spec.ts", + "src/app/core/auth/tenant-activation.service.spec.ts", "src/app/core/console/console-status.service.spec.ts", "src/app/features/change-trace/change-trace-viewer.component.spec.ts", "src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts", @@ -15,6 +19,8 @@ "src/app/features/policy-simulation/policy-simulation-defaults.spec.ts", "src/app/features/policy-simulation/simulation-dashboard.component.spec.ts", "src/app/features/registry-admin/registry-admin.component.spec.ts", + "src/app/features/triage/services/ttfs-telemetry.service.spec.ts", + "src/app/features/triage/triage-workspace.component.spec.ts", "src/app/features/watchlist/watchlist-page.component.spec.ts", "src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts" ]