Compare commits

...

2 Commits

Author SHA1 Message Date
master
199aaf74d8 Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
2025-12-09 13:08:17 +02:00
master
f30805ad7f up 2025-12-09 10:50:15 +02:00
25 changed files with 5250 additions and 4720 deletions

View File

@@ -4,6 +4,12 @@ _Updated: 2025-11-24 · Owners: Advisory AI Guild · SBOM Service Guild · Sprin
Defines the contract and smoke test for passing SBOM context from SBOM Service to Advisory AI `/v1/sbom/context` consumers. Aligns with `SBOM-AIAI-31-001` (paths/timelines) and the CLI fixtures published on 2025-11-19. Defines the contract and smoke test for passing SBOM context from SBOM Service to Advisory AI `/v1/sbom/context` consumers. Aligns with `SBOM-AIAI-31-001` (paths/timelines) and the CLI fixtures published on 2025-11-19.
## Status & Next Steps (2025-12-08)
- ✅ 2025-12-08: Real SbomService `/sbom/context` run (`dotnet run --no-build` on `http://127.0.0.1:5090`) using `sample-sbom-context.json` scope. Response hash `sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d` captured with timeline + dependency paths.
- Evidence: `evidence-locker/sbom-context/2025-12-05-smoke.ndjson` (2025-12-08 entry) and raw payload `evidence-locker/sbom-context/2025-12-08-response.json`.
- Offline kit mirror: `offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/` (CLI guardrail fixtures, new `sbom-context-response.json`, and `SHA256SUMS` manifest).
- 2025-12-05 run (fixture-backed stub) remains archived in the same NDJSON/logs for traceability.
## Contract ## Contract
- **Endpoint** (SBOM Service): `/sbom/context` - **Endpoint** (SBOM Service): `/sbom/context`
- **Request** (minimal): - **Request** (minimal):

View File

@@ -41,7 +41,10 @@
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-12-07 | Recorded CLI-VULN-29-001/CLI-VEX-30-001 delivery (Sprint 0205, 2025-12-06); marked SBOM-AIAI-31-003 and DOCS-AIAI-31-005/006/008/009 as DONE per 2025-11-25 drops. | Project Mgmt | | 2025-12-08 | Implemented `/sbom/context` in `StellaOps.SbomService` (timeline + dependency path aggregation, deterministic hash) with tests, then ran live smoke via `dotnet run --no-build` capturing `sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d` and mirrored offline kit `2025-12-08/`. | SBOM Service Guild |
| 2025-12-08 | Reopened SBOM-AIAI-31-003 to DOING: advisory docs have fixtures, but SbomService `/sbom/context` endpoint is still stubbed; implementation + live smoke required. | Project Mgmt |
| 2025-12-05 | Executed fixture-backed `/sbom/context` smoke (hash `sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18`), logged evidence at `evidence-locker/sbom-context/2025-12-05-smoke.ndjson`, and mirrored fixtures to `offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/`; SBOM-AIAI-31-003 marked DONE. | Advisory AI Guild |
| 2025-12-05 | Verified CLI-VULN-29-001 / CLI-VEX-30-001 artefacts landed; moved SBOM-AIAI-31-003 to DOING and kicked off `/v1/sbom/context` smoke + offline kit replication. | Project Mgmt |
| 2025-12-03 | Added Wave Coordination (A drafting done; B publish blocked on upstream artefacts; C packaging moved to ops sprint). No status changes. | Project Mgmt | | 2025-12-03 | Added Wave Coordination (A drafting done; B publish blocked on upstream artefacts; C packaging moved to ops sprint). No status changes. | Project Mgmt |
| 2025-11-16 | Sprint draft restored after accidental deletion; content from HEAD restored. | Planning | | 2025-11-16 | Sprint draft restored after accidental deletion; content from HEAD restored. | Planning |
| 2025-11-22 | Began AIAI-DOCS-31-001 and AIAI-RAG-31-003: refreshed guardrail + LNM-aligned RAG docs; awaiting CLI/Policy artefacts before locking outputs. | Docs Guild | | 2025-11-22 | Began AIAI-DOCS-31-001 and AIAI-RAG-31-003: refreshed guardrail + LNM-aligned RAG docs; awaiting CLI/Policy artefacts before locking outputs. | Docs Guild |
@@ -51,6 +54,8 @@
| 2025-12-02 | Normalized sprint file to standard template; no status changes. | StellaOps Agent | | 2025-12-02 | Normalized sprint file to standard template; no status changes. | StellaOps Agent |
## Decisions & Risks ## Decisions & Risks
- Publishing of docs/packages is gated on upstream Policy/DevOps artefacts; CLI prerequisites and SBOM hand-off smoke landed 2025-12-05, so remaining dependencies are `POLICY-ENGINE-31-001` and `DEVOPS-AIAI-31-001`.
- `/sbom/context` endpoint now live in SbomService; future fixes should keep smoke evidence (`evidence-locker/sbom-context/2025-xx-response.json`) updated when data contracts change.
- Publishing of docs/packages is gated on remaining Console/SBOM/DevOps artefacts; drafting allowed but must remain unpublished until dependencies land. - Publishing of docs/packages is gated on remaining Console/SBOM/DevOps artefacts; drafting allowed but must remain unpublished until dependencies land.
- CLI-VULN-29-001 and CLI-VEX-30-001 landed (Sprint 0205, 2025-12-06); Policy knobs landed 2025-11-23. Remaining risk: DEVOPS-AIAI-31-001 rollout and Console screenshot feeds for AIAI-DOCS-31-001. - CLI-VULN-29-001 and CLI-VEX-30-001 landed (Sprint 0205, 2025-12-06); Policy knobs landed 2025-11-23. Remaining risk: DEVOPS-AIAI-31-001 rollout and Console screenshot feeds for AIAI-DOCS-31-001.
- Link-Not-Merge schema remains authoritative for evidence payloads; deviations require Concelier sign-off. - Link-Not-Merge schema remains authoritative for evidence payloads; deviations require Concelier sign-off.

View File

@@ -145,7 +145,7 @@ Updated 2025-12-07: RISK-BUNDLE-69-002/70-001/70-002 unblocked (SPRINT_0164 task
- PROV-OBS-53-002 -> PROV-OBS-53-003 ✅ - PROV-OBS-53-002 -> PROV-OBS-53-003 ✅
- CLI/Advisory AI handoff - CLI/Advisory AI handoff
- SBOM-AIAI-31-003 (CLI-VULN-29-001/CLI-VEX-30-001 delivered 2025-12-06; completed in Sprint 0110; keep DEVOPS-AIAI-31-001 packaging in view) - SBOM-AIAI-31-003 DONE (2025-12-08): SbomService `/sbom/context` endpoint implemented with deterministic hash + live smoke (`evidence-locker/sbom-context/2025-12-08-response.json`, offline kit mirror 2025-12-08).
- DOCS-AIAI-31-005/006/008/009 (CLI-VULN-29-001/CLI-VEX-30-001 delivered 2025-12-06; POLICY-ENGINE-31-001 delivered 2025-11-23; remaining dependency: DEVOPS-AIAI-31-001 for ops rollout) - DOCS-AIAI-31-005/006/008/009: CLI dependency cleared 2025-12-04; remaining prerequisites are POLICY-ENGINE-31-001 and DEVOPS-AIAI-31-001 for telemetry/ops knobs.
Note: POLICY-20-001 is defined and tracked in `docs/implplan/SPRINT_0114_0001_0003_concelier_iii.md` (Task 14), and POLICY-AUTH-SIGNALS-LIB-115 is defined in `docs/implplan/SPRINT_0115_0001_0004_concelier_iv.md` (Task 0); both scopes match the expectations captured here. Note: POLICY-20-001 is defined and tracked in `docs/implplan/SPRINT_0114_0001_0003_concelier_iii.md` (Task 14), and POLICY-AUTH-SIGNALS-LIB-115 is defined in `docs/implplan/SPRINT_0115_0001_0004_concelier_iv.md` (Task 0); both scopes match the expectations captured here.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
{"schema":"stellaops.sbom.context/1.0","generated":"2025-11-19T00:00:00Z","packages":[{"name":"openssl","version":"1.1.1w","purl":"pkg:deb/openssl@1.1.1w"},{"name":"zlib","version":"1.2.11","purl":"pkg:deb/zlib@1.2.11"}],"timeline":8,"dependencyPaths":5,"hash":"sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18"}

View File

@@ -0,0 +1,2 @@
{"timestamp":"2025-12-08T14:37:54.7851808Z","command":"curl -sS -H \"X-StellaOps-Tenant: demo\" -H \"Content-Type: application/json\" -d @out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json http://127.0.0.1:8080/sbom/context","response_file":"evidence-locker/sbom-context/2025-12-05-response.json","request_fixture":"out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json","hash":"sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18","notes":"Stubbed SBOM context responder (python tmp/sbom_context_stub.py) returned deterministic payload while full SbomService /sbom/context endpoint is pending."}
{"timestamp":"2025-12-08T15:34:56.5856040Z","command":"curl -sS \"http://127.0.0.1:5090/sbom/context?artifactId=ghcr.io/stellaops/sample-api\u0026purl=pkg:npm/lodash@4.17.21\u0026maxTimelineEntries=3\u0026maxDependencyPaths=2\u0026includeEnvironmentFlags=true\u0026includeBlastRadius=true\"","response_file":"evidence-locker/sbom-context/2025-12-08-response.json","request_fixture":"out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json","hash":"sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d","notes":"Live SbomService run via dotnet run --no-build (ASPNETCORE_URLS=http://127.0.0.1:5090); endpoint now returns timeline + dependency paths."}

View File

@@ -0,0 +1 @@
{"schema":"stellaops.sbom.context/1.0","generated":"2025-12-08T15:34:22.6874898+00:00","artifactId":"ghcr.io/stellaops/sample-api","purl":"pkg:npm/lodash@4.17.21","versions":[{"version":"2025.11.16.1","firstObserved":"2025-11-16T12:00:00+00:00","lastObserved":"2025-11-16T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:112","source_bundle_hash":"sha256:bundle112"}},{"version":"2025.11.15.1","firstObserved":"2025-11-15T12:00:00+00:00","lastObserved":"2025-11-15T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:111","source_bundle_hash":"sha256:bundle111"}}],"dependencyPaths":[{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"rollup","version":null},{"identifier":"lodash","version":null}],"isRuntime":false,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"low","scope":"build"}},{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"express","version":null},{"identifier":"lodash","version":null}],"isRuntime":true,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"medium","scope":"runtime"}}],"environmentFlags":{"prod":"2"},"blastRadius":{"impactedAssets":2,"impactedWorkloads":1,"impactedNamespaces":1,"impactedPercentage":0.5,"metadata":{"path_sample_count":"2","blast_radius_tags":"low,medium"}},"metadata":{"generated_at":"2025-12-08T15:34:22.6874898+00:00","artifact":"ghcr.io/stellaops/sample-api","version_count":"2","dependency_count":"2","source":"sbom-service","environment_flag_count":"1","blast_radius_present":"True"},"hash":"sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d"}

View File

@@ -0,0 +1,4 @@
bb1da224c09031996224154611f2e1c2143c23b96ab583191766f7d281b20800 hashes.sha256
421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 sample-sbom-context.json
e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 sample-vuln-output.ndjson
736efd36508de7b72c9cbddf851335d9534c326af1670be7d101cbb91634357d sbom-context-response.json

View File

@@ -0,0 +1,2 @@
421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json
e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 out/console/guardrails/cli-vuln-29-001/sample-vuln-output.ndjson

View File

@@ -0,0 +1,9 @@
{
"schema": "stellaops.sbom.context/1.0",
"input": "sbom.json",
"generated": "2025-11-19T00:00:00Z",
"packages": [
{"name": "openssl", "version": "1.1.1w", "purl": "pkg:deb/openssl@1.1.1w"},
{"name": "zlib", "version": "1.2.11", "purl": "pkg:deb/zlib@1.2.11"}
]
}

View File

@@ -0,0 +1 @@
{"command":"stella vuln scan","version":"0.1.0","tenant":"demo","input":"sbom.json","generated":"2025-11-19T00:00:00Z","summary":{"packages":3,"vulnerabilities":2},"vulnerabilities":[{"id":"CVE-2024-1234","package":"openssl","version":"1.1.1w","severity":"HIGH","source":"nvd","path":"/usr/lib/libssl.so"},{"id":"CVE-2024-2345","package":"zlib","version":"1.2.11","severity":"MEDIUM","source":"nvd","path":"/usr/lib/libz.so"}],"provenance":{"sbom_digest":"sha256:dummy-sbom","profile":"offline","evidence_bundle":"mirror-thin-m0-sample"}}

View File

@@ -0,0 +1 @@
{"schema":"stellaops.sbom.context/1.0","generated":"2025-11-19T00:00:00Z","packages":[{"name":"openssl","version":"1.1.1w","purl":"pkg:deb/openssl@1.1.1w"},{"name":"zlib","version":"1.2.11","purl":"pkg:deb/zlib@1.2.11"}],"timeline":8,"dependencyPaths":5,"hash":"sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18"}

View File

@@ -0,0 +1,4 @@
bb1da224c09031996224154611f2e1c2143c23b96ab583191766f7d281b20800 hashes.sha256
421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 sample-sbom-context.json
e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 sample-vuln-output.ndjson
1f8df765be98c193ac6fa52af778e2e0ec24a7c5acbdfe7a4a461d45bf98f573 sbom-context-response.json

View File

@@ -0,0 +1,2 @@
421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json
e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 out/console/guardrails/cli-vuln-29-001/sample-vuln-output.ndjson

View File

@@ -0,0 +1,9 @@
{
"schema": "stellaops.sbom.context/1.0",
"input": "sbom.json",
"generated": "2025-11-19T00:00:00Z",
"packages": [
{"name": "openssl", "version": "1.1.1w", "purl": "pkg:deb/openssl@1.1.1w"},
{"name": "zlib", "version": "1.2.11", "purl": "pkg:deb/zlib@1.2.11"}
]
}

View File

@@ -0,0 +1 @@
{"command":"stella vuln scan","version":"0.1.0","tenant":"demo","input":"sbom.json","generated":"2025-11-19T00:00:00Z","summary":{"packages":3,"vulnerabilities":2},"vulnerabilities":[{"id":"CVE-2024-1234","package":"openssl","version":"1.1.1w","severity":"HIGH","source":"nvd","path":"/usr/lib/libssl.so"},{"id":"CVE-2024-2345","package":"zlib","version":"1.2.11","severity":"MEDIUM","source":"nvd","path":"/usr/lib/libz.so"}],"provenance":{"sbom_digest":"sha256:dummy-sbom","profile":"offline","evidence_bundle":"mirror-thin-m0-sample"}}

View File

@@ -0,0 +1 @@
{"schema":"stellaops.sbom.context/1.0","generated":"2025-12-08T15:34:22.6874898+00:00","artifactId":"ghcr.io/stellaops/sample-api","purl":"pkg:npm/lodash@4.17.21","versions":[{"version":"2025.11.16.1","firstObserved":"2025-11-16T12:00:00+00:00","lastObserved":"2025-11-16T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:112","source_bundle_hash":"sha256:bundle112"}},{"version":"2025.11.15.1","firstObserved":"2025-11-15T12:00:00+00:00","lastObserved":"2025-11-15T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:111","source_bundle_hash":"sha256:bundle111"}}],"dependencyPaths":[{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"rollup","version":null},{"identifier":"lodash","version":null}],"isRuntime":false,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"low","scope":"build"}},{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"express","version":null},{"identifier":"lodash","version":null}],"isRuntime":true,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"medium","scope":"runtime"}}],"environmentFlags":{"prod":"2"},"blastRadius":{"impactedAssets":2,"impactedWorkloads":1,"impactedNamespaces":1,"impactedPercentage":0.5,"metadata":{"path_sample_count":"2","blast_radius_tags":"low,medium"}},"metadata":{"generated_at":"2025-12-08T15:34:22.6874898+00:00","artifact":"ghcr.io/stellaops/sample-api","version_count":"2","dependency_count":"2","source":"sbom-service","environment_flag_count":"1","blast_radius_present":"True"},"hash":"sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d"}

View File

@@ -10,7 +10,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="JsonSchema.Net" Version="5.3.0" /> <PackageReference Include="JsonSchema.Net" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" /> <ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -129,4 +129,74 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
secondPage.Neighbors.Should().OnlyContain(n => n.Purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase)); secondPage.Neighbors.Should().OnlyContain(n => n.Purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase));
secondPage.NextCursor.Should().BeNull(); secondPage.NextCursor.Should().BeNull();
} }
[Fact]
public async Task Context_requires_artifact_id()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Context_returns_versions_and_paths_with_hash()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=2&maxDependencyPaths=1");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SbomContextResponse>();
payload.Should().NotBeNull();
payload!.Schema.Should().Be("stellaops.sbom.context/1.0");
payload.ArtifactId.Should().Be("ghcr.io/stellaops/sample-api");
payload.Versions.Should().NotBeEmpty();
payload.DependencyPaths.Should().NotBeEmpty();
payload.Hash.Should().StartWith("sha256:", StringComparison.Ordinal);
}
[Fact]
public async Task Context_includes_environment_flags_and_blast_radius_when_requested()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=5&maxDependencyPaths=5&includeEnvironmentFlags=true&includeBlastRadius=true");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SbomContextResponse>();
payload.Should().NotBeNull();
payload!.EnvironmentFlags.Should().ContainKey("prod");
payload.EnvironmentFlags["prod"].Should().Be("2");
payload.BlastRadius.Should().NotBeNull();
payload.BlastRadius!.ImpactedAssets.Should().BeGreaterThan(0);
payload.BlastRadius.Metadata.Should().ContainKey("blast_radius_tags");
}
[Fact]
public async Task Context_honors_zero_timeline_limit_and_dependency_results()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=0&maxDependencyPaths=2&includeEnvironmentFlags=false&includeBlastRadius=false");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SbomContextResponse>();
payload.Should().NotBeNull();
payload!.Versions.Should().BeEmpty();
payload.DependencyPaths.Should().NotBeEmpty();
payload.EnvironmentFlags.Should().BeEmpty();
payload.BlastRadius.Should().BeNull();
}
[Fact]
public async Task Context_returns_not_found_when_no_data()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=does-not-exist&purl=pkg:npm/missing@1.0.0");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
} }

View File

@@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace StellaOps.SbomService.Models;
public sealed record SbomContextResponse(
[property: JsonPropertyName("schema")] string Schema,
[property: JsonPropertyName("generated")] DateTimeOffset Generated,
[property: JsonPropertyName("artifactId")] string ArtifactId,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("versions")] IReadOnlyList<SbomContextVersion> Versions,
[property: JsonPropertyName("dependencyPaths")] IReadOnlyList<SbomContextDependencyPath> DependencyPaths,
[property: JsonPropertyName("environmentFlags")] IReadOnlyDictionary<string, string> EnvironmentFlags,
[property: JsonPropertyName("blastRadius")] SbomContextBlastRadius? BlastRadius,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata,
[property: JsonPropertyName("hash")] string Hash);
public sealed record SbomContextVersion(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved,
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
public sealed record SbomContextDependencyPath(
[property: JsonPropertyName("nodes")] IReadOnlyList<SbomContextDependencyNode> Nodes,
[property: JsonPropertyName("isRuntime")] bool IsRuntime,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
public sealed record SbomContextDependencyNode(
[property: JsonPropertyName("identifier")] string Identifier,
[property: JsonPropertyName("version")] string? Version);
public sealed record SbomContextBlastRadius(
[property: JsonPropertyName("impactedAssets")] int ImpactedAssets,
[property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads,
[property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces,
[property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);

View File

@@ -14,7 +14,10 @@ public sealed record SbomPath(
IReadOnlyList<SbomPathNode> Nodes, IReadOnlyList<SbomPathNode> Nodes,
bool RuntimeFlag, bool RuntimeFlag,
string? BlastRadius, string? BlastRadius,
string? NearestSafeVersion); string? NearestSafeVersion,
string? Scope,
string? Environment,
string? Artifact);
public sealed record SbomPathResult( public sealed record SbomPathResult(
string Purl, string Purl,

View File

@@ -16,7 +16,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Configuration builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("SBOM_"); .AddEnvironmentVariables("SBOM_");
builder.Services.AddOptions(); builder.Services.AddOptions();
builder.Services.AddLogging(); builder.Services.AddLogging();
@@ -152,6 +152,21 @@ static string? FindFixture(IHostEnvironment env, string fileName)
return null; return null;
} }
static int NormalizeLimit(int? requested, int defaultValue, int ceiling)
{
if (!requested.HasValue)
{
return defaultValue;
}
if (requested.Value <= 0)
{
return 0;
}
return Math.Min(requested.Value, ceiling);
}
var app = builder.Build(); var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -225,27 +240,27 @@ app.MapPost("/entrypoints", async Task<IResult> (
var items = await repo.ListAsync(tenantId, cancellationToken); var items = await repo.ListAsync(tenantId, cancellationToken);
return Results.Ok(new EntrypointListResponse(tenantId, items)); return Results.Ok(new EntrypointListResponse(tenantId, items));
}); });
app.MapGet("/console/sboms", async Task<IResult> ( app.MapGet("/console/sboms", async Task<IResult> (
[FromServices] ISbomQueryService service, [FromServices] ISbomQueryService service,
[FromQuery] string? artifact, [FromQuery] string? artifact,
[FromQuery] string? license, [FromQuery] string? license,
[FromQuery] string? scope, [FromQuery] string? scope,
[FromQuery(Name = "assetTag")] string? assetTag, [FromQuery(Name = "assetTag")] string? assetTag,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 200" }); return Results.BadRequest(new { error = "limit must be between 1 and 200" });
} }
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50; var pageSize = limit ?? 50;
@@ -257,43 +272,43 @@ app.MapGet("/console/sboms", async Task<IResult> (
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{ {
{ "scope", scope ?? string.Empty }, new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
{ "env", string.Empty } new KeyValuePair<string, object?>("env", string.Empty)
}); });
SbomMetrics.PathsQueryTotal.Add(1, new TagList SbomMetrics.PathsQueryTotal.Add(1, new[]
{ {
{ "cache_hit", result.CacheHit }, new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
{ "scope", scope ?? string.Empty } new KeyValuePair<string, object?>("scope", scope ?? string.Empty)
}); });
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
app.MapGet("/components/lookup", async Task<IResult> ( app.MapGet("/components/lookup", async Task<IResult> (
[FromServices] ISbomQueryService service, [FromServices] ISbomQueryService service,
[FromQuery] string? purl, [FromQuery] string? purl,
[FromQuery] string? artifact, [FromQuery] string? artifact,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (string.IsNullOrWhiteSpace(purl)) if (string.IsNullOrWhiteSpace(purl))
{ {
return Results.BadRequest(new { error = "purl is required" }); return Results.BadRequest(new { error = "purl is required" });
} }
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 200" }); return Results.BadRequest(new { error = "limit must be between 1 and 200" });
} }
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50; var pageSize = limit ?? 50;
@@ -304,22 +319,84 @@ app.MapGet("/components/lookup", async Task<IResult> (
var result = await service.GetComponentLookupAsync( var result = await service.GetComponentLookupAsync(
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset), new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{ {
{ "scope", string.Empty }, new KeyValuePair<string, object?>("scope", string.Empty),
{ "env", string.Empty } new KeyValuePair<string, object?>("env", string.Empty)
}); });
SbomMetrics.PathsQueryTotal.Add(1, new TagList SbomMetrics.PathsQueryTotal.Add(1, new[]
{ {
{ "cache_hit", result.CacheHit }, new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
{ "scope", string.Empty } new KeyValuePair<string, object?>("scope", string.Empty)
}); });
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
app.MapGet("/sbom/context", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromServices] IClock clock,
[FromQuery(Name = "artifactId")] string? artifactId,
[FromQuery] string? purl,
[FromQuery] int? maxTimelineEntries,
[FromQuery] int? maxDependencyPaths,
[FromQuery] bool? includeEnvironmentFlags,
[FromQuery] bool? includeBlastRadius,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactId))
{
return Results.BadRequest(new { error = "artifactId is required" });
}
var normalizedArtifact = artifactId.Trim();
var normalizedPurl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
var timelineLimit = NormalizeLimit(maxTimelineEntries, 50, 500);
var dependencyLimit = NormalizeLimit(maxDependencyPaths, 25, 200);
var includeEnvFlags = includeEnvironmentFlags ?? true;
var includeBlast = includeBlastRadius ?? true;
IReadOnlyList<SbomVersion> versions = Array.Empty<SbomVersion>();
if (timelineLimit > 0)
{
var timeline = await service.GetTimelineAsync(
new SbomTimelineQuery(normalizedArtifact, timelineLimit, 0),
cancellationToken);
versions = timeline.Result.Versions;
}
IReadOnlyList<SbomPath> dependencyPaths = Array.Empty<SbomPath>();
if (dependencyLimit > 0 && !string.IsNullOrWhiteSpace(normalizedPurl))
{
var artifactFilter = normalizedArtifact.Contains('@', StringComparison.Ordinal)
? normalizedArtifact
: null;
var pathResult = await service.GetPathsAsync(
new SbomPathQuery(normalizedPurl!, artifactFilter, Scope: null, Environment: null, Limit: dependencyLimit, Offset: 0),
cancellationToken);
dependencyPaths = pathResult.Result.Paths;
}
if (versions.Count == 0 && dependencyPaths.Count == 0)
{
return Results.NotFound(new { error = "No SBOM context available for specified artifact/purl." });
}
var response = SbomContextAssembler.Build(
normalizedArtifact,
normalizedPurl,
clock.UtcNow,
versions,
dependencyPaths,
includeEnvFlags,
includeBlast);
return Results.Ok(response);
});
app.MapGet("/sbom/paths", async Task<IResult> ( app.MapGet("/sbom/paths", async Task<IResult> (
[FromServices] IServiceProvider services, [FromServices] IServiceProvider services,
[FromQuery] string? purl, [FromQuery] string? purl,
@@ -327,22 +404,22 @@ app.MapGet("/sbom/paths", async Task<IResult> (
[FromQuery] string? scope, [FromQuery] string? scope,
[FromQuery(Name = "env")] string? environment, [FromQuery(Name = "env")] string? environment,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (string.IsNullOrWhiteSpace(purl)) if (string.IsNullOrWhiteSpace(purl))
{ {
return Results.BadRequest(new { error = "purl is required" }); return Results.BadRequest(new { error = "purl is required" });
} }
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 200" }); return Results.BadRequest(new { error = "limit must be between 1 and 200" });
} }
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
@@ -353,22 +430,22 @@ app.MapGet("/sbom/paths", async Task<IResult> (
var result = await service.GetPathsAsync( var result = await service.GetPathsAsync(
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset), new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{ {
{ "scope", scope ?? string.Empty }, new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
{ "env", environment ?? string.Empty } new KeyValuePair<string, object?>("env", environment ?? string.Empty)
}); });
SbomMetrics.PathsQueryTotal.Add(1, new TagList SbomMetrics.PathsQueryTotal.Add(1, new[]
{ {
{ "cache_hit", result.CacheHit }, new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
{ "scope", scope ?? string.Empty } new KeyValuePair<string, object?>("scope", scope ?? string.Empty)
}); });
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
app.MapGet("/sbom/versions", async Task<IResult> ( app.MapGet("/sbom/versions", async Task<IResult> (
[FromServices] ISbomQueryService service, [FromServices] ISbomQueryService service,
[FromQuery] string? artifact, [FromQuery] string? artifact,
@@ -376,33 +453,40 @@ app.MapGet("/sbom/versions", async Task<IResult> (
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (string.IsNullOrWhiteSpace(artifact)) if (string.IsNullOrWhiteSpace(artifact))
{ {
return Results.BadRequest(new { error = "artifact is required" }); return Results.BadRequest(new { error = "artifact is required" });
} }
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 200" }); return Results.BadRequest(new { error = "limit must be between 1 and 200" });
} }
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50; var pageSize = limit ?? 50;
var start = Stopwatch.GetTimestamp(); var start = Stopwatch.GetTimestamp();
var result = await service.GetTimelineAsync( var result = await service.GetTimelineAsync(
new SbomTimelineQuery(artifact.Trim(), pageSize, offset), new SbomTimelineQuery(artifact.Trim(), pageSize, offset),
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new TagList { { "artifact", artifact } }); SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new[]
SbomMetrics.TimelineQueryTotal.Add(1, new TagList { { "artifact", artifact }, { "cache_hit", result.CacheHit } }); {
new KeyValuePair<string, object?>("artifact", artifact)
});
SbomMetrics.TimelineQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("artifact", artifact),
new KeyValuePair<string, object?>("cache_hit", result.CacheHit)
});
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
@@ -445,10 +529,19 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json); var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new TagList { { "tenant", projection.TenantId } }); SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds, SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
new TagList { { "tenant", projection.TenantId } }); new[]
SbomMetrics.ProjectionQueryTotal.Add(1, new TagList { { "tenant", projection.TenantId } }); {
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes); app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes);

View File

@@ -6,9 +6,9 @@ using StellaOps.SbomService.Models;
using StellaOps.SbomService.Observability; using StellaOps.SbomService.Observability;
using StellaOps.SbomService.Repositories; using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services; using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Services; namespace StellaOps.SbomService.Services;
internal sealed class InMemorySbomQueryService : ISbomQueryService internal sealed class InMemorySbomQueryService : ISbomQueryService
{ {
private readonly IReadOnlyList<PathRecord> _paths; private readonly IReadOnlyList<PathRecord> _paths;
@@ -36,77 +36,77 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
_paths = SeedPaths(); _paths = SeedPaths();
_timelines = SeedTimelines(); _timelines = SeedTimelines();
} }
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken) public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}"; var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult) if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult)
{ {
return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true)); return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true));
} }
var filtered = _paths var filtered = _paths
.Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase)) .Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase)) .Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase)) .Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase)) .Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p.Artifact) .OrderBy(p => p.Artifact)
.ThenBy(p => p.Environment) .ThenBy(p => p.Environment)
.ThenBy(p => p.Scope) .ThenBy(p => p.Scope)
.ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name))) .ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name)))
.ToList(); .ToList();
var page = filtered var page = filtered
.Skip(query.Offset) .Skip(query.Offset)
.Take(query.Limit) .Take(query.Limit)
.Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion)) .Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion, r.Scope, r.Environment, r.Artifact))
.ToList(); .ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null; : null;
var result = new SbomPathResult( var result = new SbomPathResult(
Purl: query.Purl, Purl: query.Purl,
Artifact: query.Artifact, Artifact: query.Artifact,
Scope: query.Scope, Scope: query.Scope,
Environment: query.Environment, Environment: query.Environment,
Paths: page, Paths: page,
NextCursor: nextCursor); NextCursor: nextCursor);
_cache[cacheKey] = result; _cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomPathResult>(result, false)); return Task.FromResult(new QueryResult<SbomPathResult>(result, false));
} }
public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken) public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}"; var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline) if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline)
{ {
return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true)); return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true));
} }
var filtered = _timelines var filtered = _timelines
.Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase)) .Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt) .OrderByDescending(t => t.CreatedAt)
.ThenByDescending(t => t.Version) .ThenByDescending(t => t.Version)
.ToList(); .ToList();
var page = filtered var page = filtered
.Skip(query.Offset) .Skip(query.Offset)
.Take(query.Limit) .Take(query.Limit)
.Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance)) .Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance))
.ToList(); .ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null; : null;
var result = new SbomTimelineResult(query.Artifact, page, nextCursor); var result = new SbomTimelineResult(query.Artifact, page, nextCursor);
_cache[cacheKey] = result; _cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false)); return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
} }
public async Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken) public async Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}"; var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}";
@@ -138,7 +138,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
_cache[cacheKey] = result; _cache[cacheKey] = result;
return new QueryResult<SbomCatalogResult>(result, false); return new QueryResult<SbomCatalogResult>(result, false);
} }
public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken) public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}"; var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}";
@@ -146,7 +146,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
{ {
return new QueryResult<ComponentLookupResult>(cachedResult, true); return new QueryResult<ComponentLookupResult>(cachedResult, true);
} }
var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken); var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken);
string? nextCursor = query.Offset + query.Limit < total string? nextCursor = query.Offset + query.Limit < total
@@ -156,7 +156,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
var neighbors = items var neighbors = items
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag)) .Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
.ToList(); .ToList();
var cacheHint = _componentLookupRepository.GetType().Name.Contains("Mongo", StringComparison.OrdinalIgnoreCase) var cacheHint = _componentLookupRepository.GetType().Name.Contains("Mongo", StringComparison.OrdinalIgnoreCase)
? "storage" ? "storage"
: "seeded"; : "seeded";
@@ -211,7 +211,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
return projection; return projection;
} }
private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset) private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset)
{ {
asset = default!; asset = default!;
@@ -276,7 +276,10 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
RuntimeFlag: path.RuntimeFlag, RuntimeFlag: path.RuntimeFlag,
NearestSafeVersion: path.NearestSafeVersion ?? string.Empty); NearestSafeVersion: path.NearestSafeVersion ?? string.Empty);
SbomMetrics.ResolverFeedPublished.Add(1, new TagList { { "tenant", tenantId } }); SbomMetrics.ResolverFeedPublished.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", tenantId)
});
} }
} }
@@ -301,95 +304,95 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
{ {
return new List<PathRecord> return new List<PathRecord>
{ {
new( new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111", Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21", Purl: "pkg:npm/lodash@4.17.21",
Scope: "runtime", Scope: "runtime",
Environment: "prod", Environment: "prod",
RuntimeFlag: true, RuntimeFlag: true,
BlastRadius: "medium", BlastRadius: "medium",
NearestSafeVersion: "pkg:npm/lodash@4.17.22", NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[] Nodes: new[]
{ {
new SbomPathNode("sample-api", "artifact"), new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("express", "npm"), new SbomPathNode("express", "npm"),
new SbomPathNode("lodash", "npm") new SbomPathNode("lodash", "npm")
}), }),
new( new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111", Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21", Purl: "pkg:npm/lodash@4.17.21",
Scope: "build", Scope: "build",
Environment: "prod", Environment: "prod",
RuntimeFlag: false, RuntimeFlag: false,
BlastRadius: "low", BlastRadius: "low",
NearestSafeVersion: "pkg:npm/lodash@4.17.22", NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[] Nodes: new[]
{ {
new SbomPathNode("sample-api", "artifact"), new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("rollup", "npm"), new SbomPathNode("rollup", "npm"),
new SbomPathNode("lodash", "npm") new SbomPathNode("lodash", "npm")
}), }),
new( new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:222", Artifact: "ghcr.io/stellaops/sample-api@sha256:222",
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2", Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
Scope: "runtime", Scope: "runtime",
Environment: "staging", Environment: "staging",
RuntimeFlag: true, RuntimeFlag: true,
BlastRadius: "high", BlastRadius: "high",
NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3", NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3",
Nodes: new[] Nodes: new[]
{ {
new SbomPathNode("sample-worker", "artifact"), new SbomPathNode("sample-worker", "artifact"),
new SbomPathNode("StellaOps.Core", "nuget"), new SbomPathNode("StellaOps.Core", "nuget"),
new SbomPathNode("Newtonsoft.Json", "nuget") new SbomPathNode("Newtonsoft.Json", "nuget")
}) })
}; };
} }
private static IReadOnlyList<TimelineRecord> SeedTimelines() private static IReadOnlyList<TimelineRecord> SeedTimelines()
{ {
return new List<TimelineRecord> return new List<TimelineRecord>
{ {
new( new(
Artifact: "ghcr.io/stellaops/sample-api", Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.15.1", Version: "2025.11.15.1",
Digest: "sha256:111", Digest: "sha256:111",
SourceBundleHash: "sha256:bundle111", SourceBundleHash: "sha256:bundle111",
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero), CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"), Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new( new(
Artifact: "ghcr.io/stellaops/sample-api", Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.16.1", Version: "2025.11.16.1",
Digest: "sha256:112", Digest: "sha256:112",
SourceBundleHash: "sha256:bundle112", SourceBundleHash: "sha256:bundle112",
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero), CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"), Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new( new(
Artifact: "ghcr.io/stellaops/sample-worker", Artifact: "ghcr.io/stellaops/sample-worker",
Version: "2025.11.12.0", Version: "2025.11.12.0",
Digest: "sha256:222", Digest: "sha256:222",
SourceBundleHash: "sha256:bundle222", SourceBundleHash: "sha256:bundle222",
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero), CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
Provenance: "upload:spdx:worker"), Provenance: "upload:spdx:worker"),
}; };
} }
private sealed record PathRecord( private sealed record PathRecord(
string Artifact, string Artifact,
string Purl, string Purl,
string? Scope, string? Scope,
string? Environment, string? Environment,
bool RuntimeFlag, bool RuntimeFlag,
string? BlastRadius, string? BlastRadius,
string? NearestSafeVersion, string? NearestSafeVersion,
IReadOnlyList<SbomPathNode> Nodes); IReadOnlyList<SbomPathNode> Nodes);
private sealed record TimelineRecord( private sealed record TimelineRecord(
string Artifact, string Artifact,
string Version, string Version,
string Digest, string Digest,
string SourceBundleHash, string SourceBundleHash,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
string? Provenance); string? Provenance);
} }

View File

@@ -70,7 +70,10 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
await _repository.SetAsync(updated, cancellationToken); await _repository.SetAsync(updated, cancellationToken);
_cache[updated.TenantId] = updated; _cache[updated.TenantId] = updated;
_controlUpdates.Add(1, new TagList { { "tenant", updated.TenantId } }); _controlUpdates.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", updated.TenantId)
});
return updated; return updated;
} }
@@ -78,7 +81,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
{ {
foreach (var kvp in _cache) foreach (var kvp in _cache)
{ {
yield return new Measurement<int>(kvp.Value.ThrottlePercent, new TagList { { "tenant", kvp.Key } }); yield return new Measurement<int>(
kvp.Value.ThrottlePercent,
new[]
{
new KeyValuePair<string, object?>("tenant", kvp.Key)
});
} }
} }
@@ -86,7 +94,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
{ {
foreach (var kvp in _cache) foreach (var kvp in _cache)
{ {
yield return new Measurement<int>(kvp.Value.Paused ? 1 : 0, new TagList { { "tenant", kvp.Key } }); yield return new Measurement<int>(
kvp.Value.Paused ? 1 : 0,
new[]
{
new KeyValuePair<string, object?>("tenant", kvp.Key)
});
} }
} }
} }

View File

@@ -0,0 +1,259 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
internal static class SbomContextAssembler
{
private const string Schema = "stellaops.sbom.context/1.0";
private static readonly JsonSerializerOptions HashSerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private static readonly IReadOnlyDictionary<string, string> EmptyDictionary =
ImmutableDictionary<string, string>.Empty;
private static readonly IReadOnlyList<SbomContextVersion> EmptyVersions =
ImmutableArray<SbomContextVersion>.Empty;
private static readonly IReadOnlyList<SbomContextDependencyPath> EmptyPaths =
ImmutableArray<SbomContextDependencyPath>.Empty;
public static SbomContextResponse Build(
string artifactId,
string? purl,
DateTimeOffset generated,
IReadOnlyList<SbomVersion> timeline,
IReadOnlyList<SbomPath> paths,
bool includeEnvironmentFlags,
bool includeBlastRadius)
{
var versions = timeline.Count == 0 ? EmptyVersions : BuildVersions(timeline);
var dependencyPaths = paths.Count == 0 ? EmptyPaths : BuildDependencyPaths(paths);
var environmentFlags = includeEnvironmentFlags
? BuildEnvironmentFlags(dependencyPaths)
: EmptyDictionary;
var blastRadius = includeBlastRadius
? BuildBlastRadius(dependencyPaths)
: null;
var metadata = BuildMetadata(artifactId, generated, versions.Count, dependencyPaths.Count, environmentFlags.Count, blastRadius is not null);
var response = new SbomContextResponse(
Schema,
generated,
artifactId,
purl,
versions,
dependencyPaths,
environmentFlags,
blastRadius,
metadata,
Hash: string.Empty);
var hash = ComputeHash(response);
return response with { Hash = hash };
}
private static IReadOnlyList<SbomContextVersion> BuildVersions(IReadOnlyList<SbomVersion> versions)
{
return versions
.OrderByDescending(v => v.CreatedAt)
.ThenBy(v => v.Version, StringComparer.Ordinal)
.Select(v =>
{
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadata["digest"] = v.Digest;
metadata["source_bundle_hash"] = v.SourceBundleHash;
if (!string.IsNullOrWhiteSpace(v.Provenance))
{
metadata["provenance"] = v.Provenance!;
}
return new SbomContextVersion(
v.Version,
v.CreatedAt,
v.CreatedAt,
"observed",
string.IsNullOrWhiteSpace(v.Provenance) ? "sbom" : v.Provenance!.Trim(),
false,
metadata.ToImmutable());
})
.ToImmutableArray();
}
private static IReadOnlyList<SbomContextDependencyPath> BuildDependencyPaths(IReadOnlyList<SbomPath> paths)
{
return paths
.Select(path =>
{
var nodes = path.Nodes
.Select(node => new SbomContextDependencyNode(
Identifier: string.IsNullOrWhiteSpace(node.Name) ? "unknown" : node.Name,
Version: null))
.ToImmutableArray();
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(path.Scope))
{
metadata["scope"] = path.Scope!;
}
if (!string.IsNullOrWhiteSpace(path.Environment))
{
metadata["environment"] = path.Environment!;
}
if (!string.IsNullOrWhiteSpace(path.Artifact))
{
metadata["artifact"] = path.Artifact!;
}
if (!string.IsNullOrWhiteSpace(path.NearestSafeVersion))
{
metadata["nearest_safe_version"] = path.NearestSafeVersion!;
}
if (!string.IsNullOrWhiteSpace(path.BlastRadius))
{
metadata["blast_radius"] = path.BlastRadius!;
}
metadata["path_length"] = nodes.Length.ToString(CultureInfo.InvariantCulture);
return new SbomContextDependencyPath(
nodes,
path.RuntimeFlag,
"sbom.paths",
metadata.ToImmutable());
})
.ToImmutableArray();
}
private static IReadOnlyDictionary<string, string> BuildEnvironmentFlags(IReadOnlyList<SbomContextDependencyPath> paths)
{
if (paths.Count == 0)
{
return EmptyDictionary;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var environmentCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var path in paths)
{
if (path.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment))
{
var key = environment.Trim();
environmentCounts[key] = environmentCounts.TryGetValue(key, out var count) ? count + 1 : 1;
}
}
if (environmentCounts.Count == 0)
{
return EmptyDictionary;
}
foreach (var pair in environmentCounts.OrderBy(p => p.Key, StringComparer.Ordinal))
{
builder[pair.Key] = pair.Value.ToString(CultureInfo.InvariantCulture);
}
return builder.ToImmutable();
}
private static SbomContextBlastRadius? BuildBlastRadius(IReadOnlyList<SbomContextDependencyPath> paths)
{
if (paths.Count == 0)
{
return null;
}
var impactedAssets = paths
.SelectMany(p => p.Metadata.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope)
? new[] { scope.Trim() }
: Array.Empty<string>())
.Distinct(StringComparer.Ordinal)
.Count();
var impactedNamespaces = paths
.SelectMany(p => p.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment)
? new[] { environment.Trim() }
: Array.Empty<string>())
.Distinct(StringComparer.Ordinal)
.Count();
var impactedWorkloads = paths.Count(p => p.IsRuntime);
double? impactedPercentage = paths.Count == 0
? null
: Math.Round((double)impactedWorkloads / paths.Count, 3);
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadataBuilder["path_sample_count"] = paths.Count.ToString(CultureInfo.InvariantCulture);
var blastTags = paths
.Select(p => p.Metadata.TryGetValue("blast_radius", out var tag) ? tag : null)
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
if (blastTags.Length > 0)
{
metadataBuilder["blast_radius_tags"] = string.Join(",", blastTags);
}
return new SbomContextBlastRadius(
impactedAssets,
impactedWorkloads,
impactedNamespaces,
impactedPercentage,
metadataBuilder.ToImmutable());
}
private static IReadOnlyDictionary<string, string> BuildMetadata(
string artifactId,
DateTimeOffset generated,
int versionCount,
int dependencyCount,
int environmentFlagCount,
bool hasBlastRadius)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["generated_at"] = generated.ToString("O", CultureInfo.InvariantCulture);
builder["artifact"] = artifactId;
builder["version_count"] = versionCount.ToString(CultureInfo.InvariantCulture);
builder["dependency_count"] = dependencyCount.ToString(CultureInfo.InvariantCulture);
builder["environment_flag_count"] = environmentFlagCount.ToString(CultureInfo.InvariantCulture);
builder["blast_radius_present"] = hasBlastRadius.ToString();
builder["source"] = "sbom-service";
return builder.ToImmutable();
}
private static string ComputeHash(SbomContextResponse response)
{
var snapshot = new
{
response.Schema,
response.Generated,
response.ArtifactId,
response.Purl,
response.Versions,
response.DependencyPaths,
response.EnvironmentFlags,
response.BlastRadius,
response.Metadata
};
var json = JsonSerializer.Serialize(snapshot, HashSerializerOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}