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"
]