From 536f6249a62721494f9735dafa8b9e53db7754ea Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 8 Nov 2025 20:53:45 +0200 Subject: [PATCH] Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case - Created CycloneDX and SPDX SBOM files for both reachable and unreachable images. - Added symbols.json detailing function entry and sink points in the WordPress code. - Included runtime traces for function calls in both reachable and unreachable scenarios. - Developed OpenVEX files indicating vulnerability status and justification for both cases. - Updated README for evaluator harness to guide integration with scanner output. --- .gitea/workflows/build-test-deploy.yml | 101 + deploy/helm/stellaops/templates/core.yaml | 48 +- deploy/helm/stellaops/values-airgap.yaml | 7 + deploy/helm/stellaops/values-dev.yaml | 5 + deploy/helm/stellaops/values-mirror.yaml | 17 +- deploy/helm/stellaops/values-prod.yaml | 5 + deploy/helm/stellaops/values-stage.yaml | 5 + deploy/helm/stellaops/values.yaml | 6 + docs/11_AUTHORITY.md | 12 +- docs/19_TEST_SUITE_OVERVIEW.md | 58 +- docs/TASKS.md | 4 +- docs/advisory-ai/console.md | 35 + .../console/samples/vex-statement-sse.ndjson | 5 + .../console/samples/vuln-findings-sample.json | 84 + docs/api/console/workspaces.md | 311 + docs/dev/lnm-determinism-tests.md | 25 +- docs/implplan/SPRINTS.md | 1 + docs/implplan/SPRINT_100_identity_signing.md | 43 +- .../implplan/SPRINT_110_ingestion_evidence.md | 31 +- docs/implplan/SPRINT_120_policy_reasoning.md | 8 +- docs/implplan/SPRINT_140_runtime_signals.md | 5 + .../SPRINT_150_scheduling_automation.md | 6 +- docs/implplan/SPRINT_180_experience_sdks.md | 8 + docs/implplan/SPRINT_190_ops_offline.md | 24 +- .../SPRINT_200_documentation_process.md | 18 +- .../SPRINT_201_reachability_explainability.md | 17 + docs/modules/devops/architecture.md | 7 +- docs/modules/excititor/architecture.md | 1509 +-- .../findings-ledger/workflow-inference.md | 30 + .../modules/platform/architecture-overview.md | 1 + docs/observability/observability.md | 81 + docs/policy/gateway.md | 262 +- docs/replay/DETERMINISTIC_REPLAY.md | 69 +- docs/replay/DEVS_GUIDE_REPLAY.md | 31 +- docs/replay/TEST_STRATEGY.md | 4 +- .../crypto-routing-audit-2025-11-07.md | 113 + docs/security/dpop-mtls-rollout.md | 45 + docs/security/rootpack_ru_package.md | 68 + docs/security/rootpack_ru_validation.md | 45 + etc/policy-engine.yaml.sample | 71 +- etc/rootpack/ru/crypto.profile.yaml | 30 + etc/signals.yaml.sample | 3 +- ops/deployment/TASKS.md | 2 + ops/devops/TASKS.md | 5 +- ops/devops/sealed-mode-ci/README.md | 25 + .../20251108T130258Z/compose.ps | 8 + .../20251108T171215Z/compose.ps | 8 + .../sealed-mode-ci/authority.harness.yaml | 54 + ops/devops/sealed-mode-ci/egress_probe.py | 83 + .../sealed-mode-ci/plugins/standard.yaml | 18 + ops/devops/sealed-mode-ci/run-sealed-ci.sh | 169 + .../sealed-mode-ci/sealed-mode-compose.yml | 83 + scripts/crypto/package-rootpack-ru.sh | 57 + scripts/crypto/run-rootpack-ru-tests.sh | 51 + .../StellaOps.Aoc/AocGuardOptions.cs | 4 +- .../StellaOps.Aoc.Tests/AocWriteGuardTests.cs | 42 +- src/Attestor/StellaOps.Attestor/TASKS.md | 1 + .../TASKS.md | 6 +- .../AuthoritySecretHasher.cs | 44 +- .../Documents/AuthorityTokenDocument.cs | 4 + .../Console/ConsoleEndpointsTests.cs | 141 +- .../ClientCredentialsAndTokenHandlersTests.cs | 9251 +++++++++-------- .../TokenPersistenceIntegrationTests.cs | 21 +- .../Signing/AuthorityJwksServiceTests.cs | 4 + .../KmsAuthoritySigningKeySourceTests.cs | 2 +- .../Console/ConsoleEndpointExtensions.cs | 342 +- .../Console/ConsoleWorkspaceModels.cs | 304 + .../Console/ConsoleWorkspaceSampleService.cs | 364 + .../AuthorityOpenApiDocumentProvider.cs | 633 +- .../AuthorityOpenIddictConstants.cs | 2 + .../Handlers/ClientCredentialsHandlers.cs | 65 + .../OpenIddict/Handlers/DpopHandlers.cs | 245 +- .../Handlers/TokenPersistenceHandlers.cs | 12 + .../Handlers/TokenValidationHandlers.cs | 1054 +- .../StellaOps.Authority/Program.cs | 5 +- .../AuthoritySecretHasherInitializer.cs | 40 + .../Signing/AuthorityJwksService.cs | 364 +- src/Authority/StellaOps.Authority/TASKS.md | 352 +- .../StellaOps.Cli/Commands/CommandFactory.cs | 41 +- .../StellaOps.Cli/Commands/CommandHandlers.cs | 258 +- .../Configuration/StellaOpsCliOptions.cs | 28 +- src/Cli/StellaOps.Cli/Program.cs | 18 +- src/Cli/StellaOps.Cli/StellaOps.Cli.csproj | 2 + .../Diagnostics/IngestionMetrics.cs | 58 +- .../Extensions/AdvisoryRawRequestMapper.cs | 151 +- .../Extensions/TelemetryExtensions.cs | 26 +- .../Options/ConcelierOptions.cs | 3 + .../StellaOps.Concelier.WebService/Program.cs | 63 +- .../OpenApiDiscoveryDocumentProvider.cs | 12 +- .../StellaOps.Concelier.WebService.csproj | 2 + .../StellaOps.Concelier.WebService/TASKS.md | 176 +- src/Concelier/StellaOps.Concelier.sln | 15 + .../CccsConnector.cs | 72 +- .../Configuration/CccsOptions.cs | 15 +- .../Internal/CccsHtmlParser.cs | 28 +- .../Internal/KisaDetailParser.cs | 9 +- .../RuBduConnector.cs | 35 +- ...tellaOps.Concelier.Connector.Ru.Bdu.csproj | 3 +- .../RuNkckiConnector.cs | 67 +- ...llaOps.Concelier.Connector.Ru.Nkcki.csproj | 3 +- ...Concelier.Connector.StellaOpsMirror.csproj | 3 +- .../StellaOpsMirrorConnector.cs | 60 +- .../Internal/CiscoMapper.cs | 101 +- .../TASKS.md | 2 +- .../Aoc/AdvisoryRawWriteGuard.cs | 129 +- .../Raw/AdvisoryRawService.cs | 167 +- .../StellaOps.Concelier.Core.csproj | 3 +- .../StellaOps.Concelier.Core/TASKS.md | 2 +- .../JsonExportSnapshotBuilder.cs | 52 +- .../JsonFeedExporter.cs | 15 +- .../JsonMirrorBundleWriter.cs | 21 +- .../VulnListJsonExportPathResolver.cs | 16 +- .../StellaOps.Concelier.Merge/TASKS.md | 2 +- .../RawDocumentFactory.cs | 16 +- .../VexRawDocument.cs | 20 +- ...reAdvisoryCanonicalKeyBackfillMigration.cs | 4 +- ...AdvisoryObservationsRawLinksetMigration.cs | 2 +- .../MongoBootstrapper.cs | 41 +- .../Raw/MongoAdvisoryRawRepository.cs | 199 +- .../StellaOps.Concelier.Storage.Mongo.csproj | 11 +- ...acsc-advisories-multi.snapshot.actual.json | 207 + .../acsc-advisories-multi.snapshot.json | 406 +- .../acsc-advisories.snapshot.actual.json | 91 + .../Fixtures/acsc-advisories.snapshot.json | 177 +- .../CccsConnectorTests.cs | 21 +- .../Fixtures/certfr-advisories.snapshot.json | 429 +- .../CertIn/Fixtures/expected-advisory.json | 267 +- .../Fixtures/expected-advisory.snapshot.json | 141 + .../Fixtures/expected-CVE-2024-0001.json | 443 +- .../IcsCisa/IcsCisaConnectorMappingTests.cs | 4 +- .../Kaspersky/Fixtures/expected-advisory.json | 557 + .../Kaspersky/Fixtures/expected-advisory.json | 1070 +- .../Jvn/Fixtures/expected-advisory.json | 182 +- .../Fixtures/ru-bdu-advisories.snapshot.json | 671 +- .../Fixtures/ru-bdu-documents.snapshot.json | 20 +- .../Fixtures/ru-bdu-dtos.snapshot.json | 170 +- .../Fixtures/ru-bdu-requests.snapshot.json | 20 +- .../Fixtures/ru-bdu-state.snapshot.json | 8 +- .../RuBduConnectorSnapshotTests.cs | 616 +- ...ps.Concelier.Connector.Ru.Bdu.Tests.csproj | 3 +- .../Fixtures/nkcki-advisories.snapshot.json | 994 +- .../RuNkckiConnectorTests.cs | 30 +- ....Concelier.Connector.Ru.Nkcki.Tests.csproj | 3 +- ...ier.Connector.StellaOpsMirror.Tests.csproj | 3 +- .../StellaOpsMirrorConnectorTests.cs | 7 +- .../Fixtures/adobe-advisories.snapshot.json | 1152 +- .../Fixtures/chromium-advisory.snapshot.json | 2 +- .../CiscoMapperTests.cs | 78 +- .../Fixtures/oracle-advisories.snapshot.json | 1028 +- .../Fixtures/vmware-advisories.snapshot.json | 579 +- .../Aoc/AdvisoryRawWriteGuardTests.cs | 115 +- .../Events/AdvisoryEventLogTests.cs | 63 +- .../AdvisoryObservationFactoryTests.cs | 85 +- .../Raw/AdvisoryRawServiceTests.cs | 274 +- .../StellaOps.Concelier.Core.Tests.csproj | 3 +- .../JsonExportSnapshotBuilderTests.cs | 104 +- ...ExporterDependencyInjectionRoutineTests.cs | 5 + .../JsonFeedExporterTests.cs | 63 +- ...laOps.Concelier.Exporter.Json.Tests.csproj | 3 +- .../WebServiceEndpointsTests.cs | 37 +- .../StellaOps.EvidenceLocker/TASKS.md | 1 + .../StellaOps.Excititor.WebService/AGENTS.md | 1 + .../Contracts/VexRawContracts.cs | 114 + .../Extensions/ObservabilityExtensions.cs | 76 + .../Extensions/TelemetryExtensions.cs | 140 + .../Extensions/VexRawRequestMapper.cs | 150 + .../Options/ExcititorObservabilityOptions.cs | 53 + .../Options/ExcititorTelemetryOptions.cs | 23 + .../Program.Helpers.cs | 130 + .../StellaOps.Excititor.WebService/Program.cs | 471 +- .../Services/ExcititorHealthService.cs | 667 ++ .../Services/VexIngestOrchestrator.cs | 132 +- .../StellaOps.Excititor.WebService.csproj | 11 +- .../StellaOps.Excititor.WebService/TASKS.md | 189 +- .../Aoc/VexRawWriteGuard.cs | 54 +- .../StellaOps.Excititor.Core.csproj | 1 + .../StellaOps.Excititor.Core/TASKS.md | 2 +- .../IVexStorageContracts.cs | 2 + .../MongoVexConnectorStateRepository.cs | 43 +- .../MongoVexRawStore.cs | 567 +- .../StellaOps.Excititor.Storage.Mongo.csproj | 9 +- .../VexRawDocumentMapper.cs | 194 + .../MongoVexCacheMaintenanceTests.cs | 43 +- .../MongoVexRepositoryTests.cs | 34 +- .../MongoVexSessionConsistencyTests.cs | 68 +- .../MongoVexStatementBackfillServiceTests.cs | 70 +- .../MongoVexStoreMappingTests.cs | 43 +- .../TestMongoEnvironment.cs | 88 + .../VexMongoMigrationRunnerTests.cs | 37 +- .../BatchIngestValidationTests.cs | 451 + .../ObservabilityEndpointTests.cs | 208 + ...tellaOps.Excititor.WebService.Tests.csproj | 3 +- .../TestServiceOverrides.cs | 78 +- .../VexGuardSchemaTests.cs | 203 + .../VexRawEndpointsTests.cs | 107 + .../StellaOps.ExportCenter/TASKS.md | 3 +- .../Program.cs | 9 + .../Domain/LedgerChainIdGenerator.cs | 20 + .../Options/LedgerServiceOptions.cs | 64 + .../AttachmentEncryptionService.cs | 77 + .../Attachments/AttachmentUrlSigner.cs | 51 + .../Services/FindingWorkflowService.cs | 568 + .../Services/Security/ConsoleCsrfValidator.cs | 58 + .../StellaOps.Findings.Ledger.csproj | 4 + .../StellaOps.Findings.Ledger/TASKS.md | 4 +- .../Workflow/WorkflowMutationRequests.cs | 92 + .../FindingWorkflowServiceTests.cs | 182 + .../Compilation/PolicyComplexityAnalyzer.cs | 282 + .../Endpoints/PolicyCompilationEndpoints.cs | 257 +- .../Endpoints/PolicyPackEndpoints.cs | 541 +- .../Options/PolicyEngineOptions.cs | 395 +- src/Policy/StellaOps.Policy.Engine/Program.cs | 77 +- .../Properties/AssemblyInfo.cs | 6 +- .../Services/PolicyActivationAuditor.cs | 100 + .../Services/PolicyActivationSettings.cs | 34 + .../Services/PolicyCompilationService.cs | 158 +- .../Services/PolicyEngineDiagnosticCodes.cs | 6 + src/Policy/StellaOps.Policy.Engine/TASKS.md | 4 +- .../Contracts/PolicyPackContracts.cs | 90 +- .../PolicyActivationAuditorTests.cs | 91 + .../PolicyActivationSettingsTests.cs | 42 + .../PolicyCompilationServiceTests.cs | 136 + .../GatewayActivationTests.cs | 1183 ++- .../Cas/LocalCasClient.cs | 44 +- .../Descriptor/DescriptorGenerator.cs | 85 +- .../Program.cs | 878 +- ...ellaOps.Scanner.Sbomer.BuildXPlugin.csproj | 28 +- .../Surface/SurfaceCasLayout.cs | 10 +- .../Surface/SurfaceManifestWriter.cs | 16 +- .../Options/ScannerWebServiceOptions.cs | 6 + .../StellaOps.Scanner.WebService/Program.cs | 16 +- .../Services/SurfacePointerService.cs | 18 +- .../StellaOps.Scanner.WebService/TASKS.md | 1 + .../Options/ScannerWorkerOptions.cs | 7 +- .../CompositeScanAnalyzerDispatcher.cs | 382 +- .../Processing/EntryTraceExecutionService.cs | 24 +- .../Surface/SurfaceManifestPublisher.cs | 16 +- .../Surface/SurfaceManifestStageExecutor.cs | 14 +- .../StellaOps.Scanner.Worker/Program.cs | 2 + src/Scanner/StellaOps.Scanner.Worker/TASKS.md | 2 + .../ReachabilityGraphBuilder.cs | 121 + .../ReachabilityReplayWriter.cs | 157 + .../StellaOps.Scanner.Reachability.csproj | 13 + .../FileSurfaceManifestStore.cs | 14 +- .../StellaOps.Scanner.Surface.FS.csproj | 15 +- .../Cas/LocalCasClientTests.cs | 69 +- .../DescriptorCommandSurfaceTests.cs | 56 +- .../Descriptor/DescriptorGeneratorTests.cs | 14 +- .../Descriptor/DescriptorGoldenTests.cs | 11 +- .../Surface/SurfaceManifestWriterTests.cs | 5 +- .../CompositeScanAnalyzerDispatcherTests.cs | 6 +- .../EntryTraceExecutionServiceTests.cs | 8 +- .../SurfaceManifestStageExecutorTests.cs | 10 +- .../TestInfrastructure/TestCryptoHash.cs | 47 + .../StellaOps.Scheduler.Worker/TASKS.md | 2 + .../Models/ReachabilityFactDocument.cs | 98 + .../Models/ReachabilityRecomputeRequest.cs | 26 + .../Options/SignalsMongoOptions.cs | 30 +- .../Parsing/SimpleJsonCallgraphParser.cs | 251 +- .../Persistence/ICallgraphRepository.cs | 10 +- .../IReachabilityFactRepository.cs | 12 + .../Persistence/MongoCallgraphRepository.cs | 29 +- .../MongoReachabilityFactRepository.cs | 53 + src/Signals/StellaOps.Signals/Program.cs | 128 +- .../Properties/AssemblyInfo.cs | 4 + .../Services/IReachabilityScoringService.cs | 10 + .../Services/ReachabilityScoringService.cs | 284 + src/Signals/StellaOps.Signals/TASKS.md | 6 + src/StellaOps.sln | 15 + src/Web/StellaOps.Web/TASKS.md | 8 +- .../StellaOps.Zastava.Observer/TASKS.md | 2 + .../StellaOps.Configuration.csproj | 5 +- .../StellaOpsAuthorityOptions.cs | 5 + .../StellaOpsCryptoOptions.cs | 20 + ...llaOpsCryptoServiceCollectionExtensions.cs | 101 + .../CryptoProviderRegistryOptions.cs | 47 + .../CryptoServiceCollectionExtensions.cs | 6 +- .../StellaOps.Cryptography.Kms.csproj | 4 +- ...ptoProCryptoServiceCollectionExtensions.cs | 25 + .../CryptoProGostCryptoProvider.cs | 87 + .../CryptoProGostKeyOptions.cs | 54 + .../CryptoProGostProviderOptions.cs | 24 + ...laOps.Cryptography.Plugin.CryptoPro.csproj | 21 + .../InternalsVisibleTo.cs | 4 + .../PemUtilities.cs | 29 + ...Pkcs11CryptoServiceCollectionExtensions.cs | 25 + .../Pkcs11GostCryptoProvider.cs | 59 + .../Pkcs11GostKeyEntry.cs | 42 + .../Pkcs11GostKeyOptions.cs | 107 + .../Pkcs11GostProviderCore.cs | 186 + .../Pkcs11GostProviderOptions.cs | 27 + .../Pkcs11GostSigner.cs | 71 + .../Pkcs11Mechanisms.cs | 9 + .../Pkcs11SessionOptions.cs | 19 + .../Pkcs11SignerUtilities.cs | 107 + ...aOps.Cryptography.Plugin.Pkcs11Gost.csproj | 24 + .../CryptoHashFactory.cs | 13 + .../CryptoHashOptions.cs | 6 + .../CryptoProviderDiagnostics.cs | 21 + .../CryptoProviderMetrics.cs | 30 + .../CryptoProviderRegistry.cs | 4 + .../DefaultCryptoHash.cs | 169 + .../DefaultCryptoProvider.cs | 38 +- .../GostDigestUtilities.cs | 24 + .../StellaOps.Cryptography/HashAlgorithms.cs | 12 + .../StellaOps.Cryptography/ICryptoHash.cs | 19 + .../SignatureAlgorithms.cs | 29 +- .../StellaOps.Cryptography.csproj | 12 +- .../StellaOps.Cryptography/TASKS.md | 8 + .../IngestionTelemetry.cs | 199 + .../StellaOps.Ingestion.Telemetry.csproj | 8 + src/__Libraries/StellaOps.Plugin/TASKS.md | 1 + .../StellaOps.Replay.Core/ReplayManifest.cs | 69 + .../ReplayManifestExtensions.cs | 22 + .../StellaOps.Replay.Core.csproj | 10 + .../StellaOps.Replay.Core/TASKS.md | 1 + .../CryptoProviderRegistryTests.cs | 16 + .../DefaultCryptoHashTests.cs | 73 + tests/reachability/README.md | 15 + .../ReachabilityReplayWriterTests.cs | 61 + .../ReachbenchFixtureTests.cs | 129 + ...StellaOps.Reachability.FixtureTests.csproj | 29 + .../ReplayManifestExtensionsTests.cs | 41 + .../StellaOps.Replay.Core.Tests.csproj | 21 + .../ScannerToSignalsReachabilityTests.cs | 227 + ...Ops.ScannerSignals.IntegrationTests.csproj | 28 + .../ReachabilityScoringTests.cs | 236 + ...tellaOps.Signals.Reachability.Tests.csproj | 27 + .../reachbench-2025-expanded/INDEX.json | 444 + .../reachbench-2025-expanded/README.md | 2 + .../curl-CVE-2023-38545-socks5-heap/case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 10 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 10 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../dotnet-newtonsoft-deser-TBD/case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 10 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 10 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 10 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 10 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 10 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 10 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 10 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 10 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 10 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 10 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 10 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 10 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../python-urllib3-dos-regex-TBD/case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../rust-axum-header-parsing-TBD/case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../case.json | 46 + .../docs/README.md | 15 + .../images/reachable/attestation.dsse.json | 30 + .../images/reachable/callgraph.framework.json | 4 + .../images/reachable/callgraph.static.json | 18 + .../images/reachable/manifest.json | 8 + .../images/reachable/reachgraph.truth.json | 16 + .../images/reachable/sbom.cdx.json | 5 + .../images/reachable/sbom.spdx.json | 6 + .../images/reachable/symbols.json | 31 + .../images/reachable/traces.runtime.jsonl | 2 + .../images/reachable/vex.openvex.json | 12 + .../images/unreachable/attestation.dsse.json | 30 + .../unreachable/callgraph.framework.json | 4 + .../images/unreachable/callgraph.static.json | 18 + .../images/unreachable/manifest.json | 8 + .../images/unreachable/reachgraph.truth.json | 16 + .../images/unreachable/sbom.cdx.json | 5 + .../images/unreachable/sbom.spdx.json | 6 + .../images/unreachable/symbols.json | 31 + .../images/unreachable/traces.runtime.jsonl | 1 + .../images/unreachable/vex.openvex.json | 12 + .../harness/evaluator/README.md | 1 + 837 files changed, 37279 insertions(+), 14675 deletions(-) create mode 100644 docs/advisory-ai/console.md create mode 100644 docs/api/console/samples/vex-statement-sse.ndjson create mode 100644 docs/api/console/samples/vuln-findings-sample.json create mode 100644 docs/api/console/workspaces.md create mode 100644 docs/implplan/SPRINT_201_reachability_explainability.md create mode 100644 docs/modules/findings-ledger/workflow-inference.md create mode 100644 docs/security/crypto-routing-audit-2025-11-07.md create mode 100644 docs/security/dpop-mtls-rollout.md create mode 100644 docs/security/rootpack_ru_package.md create mode 100644 docs/security/rootpack_ru_validation.md create mode 100644 etc/rootpack/ru/crypto.profile.yaml create mode 100644 ops/devops/sealed-mode-ci/README.md create mode 100644 ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T130258Z/compose.ps create mode 100644 ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T171215Z/compose.ps create mode 100644 ops/devops/sealed-mode-ci/authority.harness.yaml create mode 100644 ops/devops/sealed-mode-ci/egress_probe.py create mode 100644 ops/devops/sealed-mode-ci/plugins/standard.yaml create mode 100644 ops/devops/sealed-mode-ci/run-sealed-ci.sh create mode 100644 ops/devops/sealed-mode-ci/sealed-mode-compose.yml create mode 100644 scripts/crypto/package-rootpack-ru.sh create mode 100644 scripts/crypto/run-rootpack-ru-tests.sh create mode 100644 src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceModels.cs create mode 100644 src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceSampleService.cs create mode 100644 src/Authority/StellaOps.Authority/StellaOps.Authority/Security/AuthoritySecretHasherInitializer.cs create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.actual.json create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.actual.json create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.snapshot.json create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Ics/Kaspersky/Fixtures/expected-advisory.json create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Contracts/VexRawContracts.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Extensions/ObservabilityExtensions.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Extensions/TelemetryExtensions.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Extensions/VexRawRequestMapper.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorObservabilityOptions.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorTelemetryOptions.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Program.Helpers.cs create mode 100644 src/Excititor/StellaOps.Excititor.WebService/Services/ExcititorHealthService.cs create mode 100644 src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexRawDocumentMapper.cs create mode 100644 src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/TestMongoEnvironment.cs create mode 100644 src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/BatchIngestValidationTests.cs create mode 100644 src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/ObservabilityEndpointTests.cs create mode 100644 src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexGuardSchemaTests.cs create mode 100644 src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexRawEndpointsTests.cs create mode 100644 src/Findings/StellaOps.Findings.Ledger/Domain/LedgerChainIdGenerator.cs create mode 100644 src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentEncryptionService.cs create mode 100644 src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentUrlSigner.cs create mode 100644 src/Findings/StellaOps.Findings.Ledger/Services/FindingWorkflowService.cs create mode 100644 src/Findings/StellaOps.Findings.Ledger/Services/Security/ConsoleCsrfValidator.cs create mode 100644 src/Findings/StellaOps.Findings.Ledger/Workflow/WorkflowMutationRequests.cs create mode 100644 src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/FindingWorkflowServiceTests.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Compilation/PolicyComplexityAnalyzer.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationAuditor.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationSettings.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/PolicyEngineDiagnosticCodes.cs create mode 100644 src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationAuditorTests.cs create mode 100644 src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationSettingsTests.cs create mode 100644 src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyCompilationServiceTests.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityGraphBuilder.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityReplayWriter.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/TestInfrastructure/TestCryptoHash.cs create mode 100644 src/Signals/StellaOps.Signals/Models/ReachabilityFactDocument.cs create mode 100644 src/Signals/StellaOps.Signals/Models/ReachabilityRecomputeRequest.cs create mode 100644 src/Signals/StellaOps.Signals/Persistence/IReachabilityFactRepository.cs create mode 100644 src/Signals/StellaOps.Signals/Persistence/MongoReachabilityFactRepository.cs create mode 100644 src/Signals/StellaOps.Signals/Properties/AssemblyInfo.cs create mode 100644 src/Signals/StellaOps.Signals/Services/IReachabilityScoringService.cs create mode 100644 src/Signals/StellaOps.Signals/Services/ReachabilityScoringService.cs create mode 100644 src/__Libraries/StellaOps.Configuration/StellaOpsCryptoOptions.cs create mode 100644 src/__Libraries/StellaOps.Configuration/StellaOpsCryptoServiceCollectionExtensions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProCryptoServiceCollectionExtensions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostCryptoProvider.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostKeyOptions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostProviderOptions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/InternalsVisibleTo.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/PemUtilities.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11CryptoServiceCollectionExtensions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostCryptoProvider.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyEntry.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyOptions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderCore.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderOptions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostSigner.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11Mechanisms.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SessionOptions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SignerUtilities.cs create mode 100644 src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj create mode 100644 src/__Libraries/StellaOps.Cryptography/CryptoHashFactory.cs create mode 100644 src/__Libraries/StellaOps.Cryptography/CryptoHashOptions.cs create mode 100644 src/__Libraries/StellaOps.Cryptography/CryptoProviderDiagnostics.cs create mode 100644 src/__Libraries/StellaOps.Cryptography/CryptoProviderMetrics.cs create mode 100644 src/__Libraries/StellaOps.Cryptography/DefaultCryptoHash.cs create mode 100644 src/__Libraries/StellaOps.Cryptography/GostDigestUtilities.cs create mode 100644 src/__Libraries/StellaOps.Cryptography/HashAlgorithms.cs create mode 100644 src/__Libraries/StellaOps.Cryptography/ICryptoHash.cs create mode 100644 src/__Libraries/StellaOps.Ingestion.Telemetry/IngestionTelemetry.cs create mode 100644 src/__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj create mode 100644 src/__Libraries/StellaOps.Replay.Core/ReplayManifest.cs create mode 100644 src/__Libraries/StellaOps.Replay.Core/ReplayManifestExtensions.cs create mode 100644 src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj create mode 100644 src/__Libraries/__Tests/StellaOps.Cryptography.Tests/DefaultCryptoHashTests.cs create mode 100644 tests/reachability/README.md create mode 100644 tests/reachability/StellaOps.Reachability.FixtureTests/ReachabilityReplayWriterTests.cs create mode 100644 tests/reachability/StellaOps.Reachability.FixtureTests/ReachbenchFixtureTests.cs create mode 100644 tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj create mode 100644 tests/reachability/StellaOps.Replay.Core.Tests/ReplayManifestExtensionsTests.cs create mode 100644 tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj create mode 100644 tests/reachability/StellaOps.ScannerSignals.IntegrationTests/ScannerToSignalsReachabilityTests.cs create mode 100644 tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj create mode 100644 tests/reachability/StellaOps.Signals.Reachability.Tests/ReachabilityScoringTests.cs create mode 100644 tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/INDEX.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/case.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/docs/README.md create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/attestation.dsse.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.framework.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.static.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/manifest.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/reachgraph.truth.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.cdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.spdx.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/symbols.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/traces.runtime.jsonl create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/vex.openvex.json create mode 100644 tests/reachability/fixtures/reachbench-2025-expanded/harness/evaluator/README.md diff --git a/.gitea/workflows/build-test-deploy.yml b/.gitea/workflows/build-test-deploy.yml index e16945c45..8d31b8eef 100644 --- a/.gitea/workflows/build-test-deploy.yml +++ b/.gitea/workflows/build-test-deploy.yml @@ -21,6 +21,8 @@ on: - 'docs/**' - 'scripts/**' - '.gitea/workflows/**' + schedule: + - cron: '0 5 * * *' workflow_dispatch: inputs: force_deploy: @@ -28,6 +30,11 @@ on: required: false default: 'false' type: boolean + excititor_batch: + description: 'Run Excititor batch-ingest validation suite' + required: false + default: 'false' + type: boolean env: DOTNET_VERSION: '10.0.100-rc.1.25451.107' @@ -48,6 +55,18 @@ jobs: tar -xzf /tmp/helm.tgz -C /tmp sudo install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm + - name: Validate Helm chart rendering + run: | + set -euo pipefail + CHART_PATH="deploy/helm/stellaops" + helm lint "$CHART_PATH" + for values in values.yaml values-dev.yaml values-stage.yaml values-prod.yaml values-airgap.yaml values-mirror.yaml; do + release="stellaops-${values%.*}" + echo "::group::Helm template ${release} (${values})" + helm template "$release" "$CHART_PATH" -f "$CHART_PATH/$values" >/dev/null + echo "::endgroup::" + done + - name: Validate deployment profiles run: ./deploy/tools/validate-profiles.sh @@ -442,6 +461,15 @@ PY if-no-files-found: error retention-days: 7 + - name: Run console endpoint tests + run: | + mkdir -p "$TEST_RESULTS_DIR" + dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj \ + --configuration $BUILD_CONFIGURATION \ + --logger "trx;LogFileName=console-endpoints.trx" \ + --results-directory "$TEST_RESULTS_DIR" \ + --filter ConsoleEndpointsTests + - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -451,6 +479,44 @@ PY if-no-files-found: ignore retention-days: 7 + sealed-mode-ci: + runs-on: ubuntu-22.04 + needs: build-test + permissions: + contents: read + packages: read + env: + COMPOSE_PROJECT_NAME: sealedmode + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Login to registry + if: ${{ secrets.REGISTRY_USERNAME != '' && secrets.REGISTRY_PASSWORD != '' }} + uses: docker/login-action@v3 + with: + registry: registry.stella-ops.org + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Run sealed-mode CI harness + working-directory: ops/devops/sealed-mode-ci + env: + COMPOSE_PROJECT_NAME: sealedmode + run: | + set -euo pipefail + ./run-sealed-ci.sh + + - name: Upload sealed-mode CI artifacts + uses: actions/upload-artifact@v4 + with: + name: sealed-mode-ci + path: ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci + if-no-files-found: error + retention-days: 14 + authority-container: runs-on: ubuntu-22.04 needs: build-test @@ -464,6 +530,41 @@ PY - name: Build Authority container image run: docker build -f ops/authority/Dockerfile -t stellaops-authority:ci . + excititor-batch-validation: + needs: build-test + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.excititor_batch == 'true') + runs-on: ubuntu-22.04 + env: + BATCH_RESULTS_DIR: ${{ github.workspace }}/artifacts/test-results/excititor-batch + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + include-prerelease: true + + - name: Run Excititor batch ingest validation suite + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: | + set -euo pipefail + mkdir -p "$BATCH_RESULTS_DIR" + dotnet test src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj \ + --configuration $BUILD_CONFIGURATION \ + --filter "Category=BatchIngestValidation" \ + --logger "trx;LogFileName=excititor-batch.trx" \ + --results-directory "$BATCH_RESULTS_DIR" + + - name: Upload Excititor batch ingest results + if: always() + uses: actions/upload-artifact@v4 + with: + name: excititor-batch-ingest-results + path: ${{ env.BATCH_RESULTS_DIR }} + docs: runs-on: ubuntu-22.04 env: diff --git a/deploy/helm/stellaops/templates/core.yaml b/deploy/helm/stellaops/templates/core.yaml index 54e96cd6a..bac2e0f89 100644 --- a/deploy/helm/stellaops/templates/core.yaml +++ b/deploy/helm/stellaops/templates/core.yaml @@ -1,5 +1,12 @@ -{{- $root := . -}} -{{- range $name, $svc := .Values.services }} +{{- $root := . -}} +{{- $configMaps := default (dict) .Values.configMaps -}} +{{- $hasPolicyActivationConfig := hasKey $configMaps "policy-engine-activation" -}} +{{- $policyActivationConfigName := "" -}} +{{- if $hasPolicyActivationConfig -}} +{{- $policyActivationConfigName = include "stellaops.fullname" (dict "root" $root "name" "policy-engine-activation") -}} +{{- end -}} +{{- $policyActivationTargets := dict "policy-engine" true "policy-gateway" true -}} +{{- range $name, $svc := .Values.services }} {{- $configMounts := (default (list) $svc.configMounts) }} apiVersion: apps/v1 kind: Deployment @@ -36,18 +43,31 @@ spec: - {{ $arg | quote }} {{- end }} {{- end }} -{{- if $svc.env }} - env: -{{- range $envName, $envValue := $svc.env }} - - name: {{ $envName }} - value: {{ $envValue | quote }} -{{- end }} -{{- end }} -{{- if $svc.envFrom }} - envFrom: -{{ toYaml $svc.envFrom | nindent 12 }} -{{- end }} -{{- if $svc.ports }} +{{- if $svc.env }} + env: +{{- range $envName, $envValue := $svc.env }} + - name: {{ $envName }} + value: {{ $envValue | quote }} +{{- end }} +{{- end }} +{{- $needsPolicyActivation := and $hasPolicyActivationConfig (hasKey $policyActivationTargets $name) }} +{{- $envFrom := default (list) $svc.envFrom }} +{{- if and $needsPolicyActivation (ne $policyActivationConfigName "") }} +{{- $hasActivationReference := false }} +{{- range $envFromEntry := $envFrom }} + {{- if and (hasKey $envFromEntry "configMapRef") (eq (index (index $envFromEntry "configMapRef") "name") $policyActivationConfigName) }} + {{- $hasActivationReference = true }} + {{- end }} +{{- end }} +{{- if not $hasActivationReference }} +{{- $envFrom = append $envFrom (dict "configMapRef" (dict "name" $policyActivationConfigName)) }} +{{- end }} +{{- end }} +{{- if $envFrom }} + envFrom: +{{ toYaml $envFrom | nindent 12 }} +{{- end }} +{{- if $svc.ports }} ports: {{- range $port := $svc.ports }} - name: {{ default (printf "%s-%v" $name $port.containerPort) $port.name | trunc 63 | trimSuffix "-" }} diff --git a/deploy/helm/stellaops/values-airgap.yaml b/deploy/helm/stellaops/values-airgap.yaml index 8b223efde..96a7aabf8 100644 --- a/deploy/helm/stellaops/values-airgap.yaml +++ b/deploy/helm/stellaops/values-airgap.yaml @@ -51,6 +51,13 @@ configMaps: telemetry: enableRequestLogging: true minimumLogLevel: Warning + policy-engine-activation: + data: + STELLAOPS_POLICY_ENGINE__ACTIVATION__FORCETWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__DEFAULTREQUIRESTWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__EMITAUDITLOGS: "true" + + services: authority: image: registry.stella-ops.org/stellaops/authority@sha256:5551a3269b7008cd5aceecf45df018c67459ed519557ccbe48b093b926a39bcc diff --git a/deploy/helm/stellaops/values-dev.yaml b/deploy/helm/stellaops/values-dev.yaml index bcd64aa0d..338875074 100644 --- a/deploy/helm/stellaops/values-dev.yaml +++ b/deploy/helm/stellaops/values-dev.yaml @@ -58,6 +58,11 @@ configMaps: telemetry: enableRequestLogging: true minimumLogLevel: Debug + policy-engine-activation: + data: + STELLAOPS_POLICY_ENGINE__ACTIVATION__FORCETWOPERSONAPPROVAL: "false" + STELLAOPS_POLICY_ENGINE__ACTIVATION__DEFAULTREQUIRESTWOPERSONAPPROVAL: "false" + STELLAOPS_POLICY_ENGINE__ACTIVATION__EMITAUDITLOGS: "true" services: authority: image: registry.stella-ops.org/stellaops/authority@sha256:a8e8faec44a579aa5714e58be835f25575710430b1ad2ccd1282a018cd9ffcdd diff --git a/deploy/helm/stellaops/values-mirror.yaml b/deploy/helm/stellaops/values-mirror.yaml index 2edce43f4..803a0eca7 100644 --- a/deploy/helm/stellaops/values-mirror.yaml +++ b/deploy/helm/stellaops/values-mirror.yaml @@ -106,11 +106,18 @@ configMaps: proxy_cache off; } - location / { - return 404; - } - -services: + location / { + return 404; + } + + + policy-engine-activation: + data: + STELLAOPS_POLICY_ENGINE__ACTIVATION__FORCETWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__DEFAULTREQUIRESTWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__EMITAUDITLOGS: "true" + +services: concelier: image: registry.stella-ops.org/stellaops/concelier@sha256:dafef3954eb4b837e2c424dd2d23e1e4d60fa83794840fac9cd3dea1d43bd085 service: diff --git a/deploy/helm/stellaops/values-prod.yaml b/deploy/helm/stellaops/values-prod.yaml index 5426b76a0..0fa18f4bb 100644 --- a/deploy/helm/stellaops/values-prod.yaml +++ b/deploy/helm/stellaops/values-prod.yaml @@ -52,6 +52,11 @@ configMaps: telemetry: enableRequestLogging: true minimumLogLevel: Information + policy-engine-activation: + data: + STELLAOPS_POLICY_ENGINE__ACTIVATION__FORCETWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__DEFAULTREQUIRESTWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__EMITAUDITLOGS: "true" services: authority: image: registry.stella-ops.org/stellaops/authority@sha256:b0348bad1d0b401cc3c71cb40ba034c8043b6c8874546f90d4783c9dbfcc0bf5 diff --git a/deploy/helm/stellaops/values-stage.yaml b/deploy/helm/stellaops/values-stage.yaml index 5d164cdb8..78f59131b 100644 --- a/deploy/helm/stellaops/values-stage.yaml +++ b/deploy/helm/stellaops/values-stage.yaml @@ -58,6 +58,11 @@ configMaps: telemetry: enableRequestLogging: true minimumLogLevel: Information + policy-engine-activation: + data: + STELLAOPS_POLICY_ENGINE__ACTIVATION__FORCETWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__DEFAULTREQUIRESTWOPERSONAPPROVAL: "true" + STELLAOPS_POLICY_ENGINE__ACTIVATION__EMITAUDITLOGS: "true" services: authority: image: registry.stella-ops.org/stellaops/authority@sha256:b0348bad1d0b401cc3c71cb40ba034c8043b6c8874546f90d4783c9dbfcc0bf5 diff --git a/deploy/helm/stellaops/values.yaml b/deploy/helm/stellaops/values.yaml index 33a81fe87..260e870d7 100644 --- a/deploy/helm/stellaops/values.yaml +++ b/deploy/helm/stellaops/values.yaml @@ -61,6 +61,12 @@ configMaps: issuerTrustCollection: issuer_trust_overrides auditCollection: issuer_audit + policy-engine-activation: + data: + STELLAOPS_POLICY_ENGINE__ACTIVATION__FORCETWOPERSONAPPROVAL: "false" + STELLAOPS_POLICY_ENGINE__ACTIVATION__DEFAULTREQUIRESTWOPERSONAPPROVAL: "false" + STELLAOPS_POLICY_ENGINE__ACTIVATION__EMITAUDITLOGS: "true" + services: issuer-directory: image: registry.stella-ops.org/stellaops/issuer-directory-web:2025.10.0-edge diff --git a/docs/11_AUTHORITY.md b/docs/11_AUTHORITY.md index 3bd78da5e..01f11d131 100644 --- a/docs/11_AUTHORITY.md +++ b/docs/11_AUTHORITY.md @@ -132,7 +132,7 @@ These registrations are provided as examples in `etc/authority.yaml.sample`. Clo - **Interactive only.** `policy:publish` and `policy:promote` are restricted to password/device-code flows (Console, CLI) and are rejected when requested via client credentials or app secrets. Tokens inherit the 5-minute fresh-auth window; resource servers reject stale tokens and emit `authority.policy_attestation_validated=false`. - **Mandatory parameters.** Requests must include: - - `policy_reason` (≤512 chars) — human-readable justification (e.g., “Promote tenant A baseline to production”). + - Authority enforces mTLS bindings on /token, /fresh-auth, and /introspect by comparing the presented TLS client certificate thumbprint against the stored claim. Requests missing a certificate or presenting a different certificate are rejected with , and the counter is incremented for operational alerts. - `policy_ticket` (≤128 chars) — change request / CAB identifier (e.g., `CR-2025-1102`). - `policy_digest` — lowercase hex digest (32–128 characters) of the policy bundle being published/promoted. - **Audit surfaces.** On success, the metadata is copied into the access token (`stellaops:policy_reason`, `stellaops:policy_ticket`, `stellaops:policy_digest`, `stellaops:policy_operation`) and recorded in [`authority.password.grant`] audit events as `policy.*` properties. @@ -142,6 +142,14 @@ These registrations are provided as examples in `etc/authority.yaml.sample`. Clo Graph Explorer introduces dedicated scopes: `graph:write` for Cartographer build jobs, `graph:read` for query/read operations, `graph:export` for long-running export downloads, and `graph:simulate` for what-if overlays. Assign only the scopes a client actually needs to preserve least privilege—UI-facing clients should typically request read/export access, while background services (Cartographer, Scheduler) require write privileges. +### Policy activation dual-control + +- **Config knobs.** `PolicyEngine.activation.forceTwoPersonApproval` forces every activation to collect two distinct `policy:activate` approvals (first response = `202 pending_second_approval`). `PolicyEngine.activation.defaultRequiresTwoPersonApproval` sets the default when callers omit the flag. +- **Operator choice.** When force is disabled, Console/CLI can opt any revision into dual-control by setting `requiresTwoPersonApproval: true`; the service persists the requirement alongside the revision metadata. +- **Audit coverage.** With `PolicyEngine.activation.emitAuditLogs` (default `true`), every activation emits structured `policy.activation.*` logger scopes (pack id, revision, actor(s), tenant, approval count, comment) so SOC pipelines can diff the two-person trail. +- **Status codes.** First approval on a dual-control revision returns `202 pending_second_approval`; duplicates produce `400 duplicate_approval`; the second distinct actor returns the usual `200 activated`. + + #### Least-privilege guidance for graph clients - **Service identities** – The Cartographer worker should request `graph:write` and `graph:read` only; grant `graph:simulate` exclusively to pipeline automation that invokes Policy Engine overlays on demand. Keep `graph:export` scoped to API gateway components responsible for streaming GraphML/JSONL artifacts. Authority enforces this by rejecting `graph:write` tokens that lack `properties.serviceIdentity: cartographer`. @@ -356,6 +364,7 @@ exceptions: | Bootstrap | `bootstrap.apiKey` | Shared secret required for `/internal/*`. | Only required when `bootstrap.enabled` is true. | ### 7.1 Sender-constrained clients (DPoP & mTLS) +> Rollout tracker: see [`docs/security/dpop-mtls-rollout.md`](security/dpop-mtls-rollout.md) for phase gates tied to `AUTH-DPOP-11-001` and `AUTH-MTLS-11-002`. Authority now understands two flavours of sender-constrained OAuth clients: @@ -386,6 +395,7 @@ Authority now understands two flavours of sender-constrained OAuth clients: - Declare client `audiences` in bootstrap manifests or plug-in provisioning metadata; Authority now defaults the token `aud` claim and `resource` indicator from this list, which is also used to trigger nonce enforcement for audiences such as `signer` and `attestor`. - **Mutual TLS clients** – client registrations may declare an mTLS binding (`senderConstraint: mtls`). When enabled via `security.senderConstraints.mtls`, Authority validates the presented client certificate against stored bindings (`certificateBindings[]`), optional chain verification, and timing windows. Successful requests embed `cnf.x5t#S256` into the access token (and introspection output) so resource servers can enforce the certificate thumbprint. - `security.senderConstraints.mtls.enforceForAudiences` forces mTLS whenever the requested `aud`/`resource` (or the client's configured audiences) intersect the configured allow-list (default includes `signer`). Clients configured for different sender constraints are rejected early so operator policy remains consistent. + - Authority enforces mTLS bindings on `/token`, `/fresh-auth`, and `/introspect` by comparing the presented TLS client certificate thumbprint against the stored `authority_sender_certificate_hex` claim. Requests missing a certificate or presenting a different certificate are rejected with `invalid_token`, and the `authority_mtls_mismatch_total{reason=...}` counter is incremented for visibility. - Certificate bindings now act as an allow-list: Authority verifies thumbprint, subject, issuer, serial number, and any declared SAN values against the presented certificate, with rotation grace windows applied to `notBefore/notAfter`. Operators can enforce subject regexes, SAN type allow-lists (`dns`, `uri`, `ip`), trusted certificate authorities, and rotation grace via `security.senderConstraints.mtls.*`. Both modes persist additional metadata in `authority_tokens`: `senderConstraint` records the enforced policy, while `senderKeyThumbprint` stores the DPoP JWK thumbprint or mTLS certificate hash captured at issuance. Downstream services can rely on these fields (and the corresponding `cnf` claim) when auditing offline copies of the token store. diff --git a/docs/19_TEST_SUITE_OVERVIEW.md b/docs/19_TEST_SUITE_OVERVIEW.md index 2c9c956ef..f9717564e 100755 --- a/docs/19_TEST_SUITE_OVERVIEW.md +++ b/docs/19_TEST_SUITE_OVERVIEW.md @@ -57,7 +57,62 @@ The script spins up MongoDB/Redis via Testcontainers and requires: * Docker ≥ 25 * Node 20 (for Jest/Playwright) ---- +#### Mongo2Go / OpenSSL shim + +Multiple suites (Concelier connectors, Excititor worker/WebService, Scheduler) +fall back to [Mongo2Go](https://github.com/Mongo2Go/Mongo2Go) when a developer +does not have a local `mongod` listening on `127.0.0.1:27017`. Modern distros +ship OpenSSL 3 by default, so you **must** expose the legacy OpenSSL 1.1 +libraries that the embedded `mongod` requires: + +1. From the repo root, export the provided binaries before running any tests: + + ```bash + export LD_LIBRARY_PATH="$(pwd)/tests/native/openssl-1.1/linux-x64:${LD_LIBRARY_PATH:-}" + ``` + +2. (Optional) If you only need the shim for a single command, prefix it: + + ```bash + LD_LIBRARY_PATH="$(pwd)/tests/native/openssl-1.1/linux-x64" \ + dotnet test src/Concelier/StellaOps.Concelier.sln --nologo + ``` + +3. CI runners or dev containers should either copy + `tests/native/openssl-1.1/linux-x64/libcrypto.so.1.1` and `libssl.so.1.1` + into a directory that is already on the default library path, or export the + `LD_LIBRARY_PATH` value shown above before invoking `dotnet test`. + +The shim lives under `tests/native/openssl-1.1/README.md` with upstream source +and licensing details. When the system already has OpenSSL 1.1 installed you +can skip this step. + +#### Local Mongo helper + +Some suites (Concelier WebService/Core, Exporter JSON) need a full +`mongod` instance when you want to debug outside of Mongo2Go (for example to +inspect data with `mongosh` or pin a specific server version). A thin wrapper +is available under `tools/mongodb/local-mongo.sh`: + +```bash +# download (cached under .cache/mongodb-local) and start a local replica set +tools/mongodb/local-mongo.sh start + +# reuse an existing data set +tools/mongodb/local-mongo.sh restart + +# stop / clean +tools/mongodb/local-mongo.sh stop +tools/mongodb/local-mongo.sh clean +``` + +By default the script downloads MongoDB 6.0.16 for Ubuntu 22.04, binds to +`127.0.0.1:27017`, and initialises a single-node replica set called `rs0`. The +current URI is printed on start, e.g. +`mongodb://127.0.0.1:27017/?replicaSet=rs0`, and you can export it before +running `dotnet test` if a suite supports overriding its connection string. + +--- ### Concelier OSV↔GHSA parity fixtures @@ -106,4 +161,3 @@ flowchart LR --- *Last updated {{ "now" | date: "%Y‑%m‑%d" }}* - diff --git a/docs/TASKS.md b/docs/TASKS.md index 147448336..cebfad38d 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -8,6 +8,7 @@ | DOCS-REPLAY-185-004 | TODO | Docs Guild, Platform Guild | REPLAY-CORE-185-001 | Expand `docs/replay/DEVS_GUIDE_REPLAY.md` with integration checklist and cross-links to sections 3 & 11 of `docs/replay/DETERMINISTIC_REPLAY.md`. | Guide updated with checklist; references validated; lint passes. | | DOCS-REPLAY-186-004 | TODO | Docs Guild, Scanner Guild | SCAN-REPLAY-186-001 | Publish `docs/replay/TEST_STRATEGY.md` detailing golden replay, feed drift, and tool upgrade verification steps; link from scanner architecture doc. | New doc merged; links verified; CI scenario notes documented. | | RUNBOOK-REPLAY-187-004 | TODO | Docs Guild, Ops Guild | EVID-REPLAY-187-001, CLI-REPLAY-187-002 | Create `/docs/runbooks/replay_ops.md` covering retention enforcement, RootPack rotation, offline kit workflows, and verification drills referencing `docs/replay/DETERMINISTIC_REPLAY.md`. | Runbook merged; rehearsal notes captured; cross-links added. | +| DOCS-REACH-201-006 | TODO | Docs Guild | ZASTAVA-REACH-201-001, SCAN-REACH-201-002, SIGNALS-REACH-201-003 | Author reachability doc suite (`docs/signals/reachability.md`, `docs/signals/callgraph-formats.md`, `docs/signals/runtime-facts.md`, CLI/UI appendices) plus embed replay evidence guidance. | Docs merged with imposed rule text; cross-links to Scanner/Zastava/Replay guides validated. | | DOCS-OBS-50-002 | TODO | Docs Guild, Security Guild | TELEMETRY-OBS-50-002 | Author `/docs/observability/telemetry-standards.md` detailing common fields, scrubbing policy, sampling defaults, and redaction override procedure. | Doc merged; imposed rule banner present; examples validated with telemetry fixtures; security review sign-off captured. | | DOCS-OBS-50-003 | TODO | Docs Guild, Observability Guild | TELEMETRY-OBS-50-001 | Create `/docs/observability/logging.md` covering structured log schema, dos/don'ts, tenant isolation, and copyable examples. | Doc merged with banner; sample logs redacted; lint passes; linked from coding standards. | | DOCS-OBS-50-004 | TODO | Docs Guild, Observability Guild | TELEMETRY-OBS-50-002 | Draft `/docs/observability/tracing.md` explaining context propagation, async linking, CLI header usage, and sampling strategies. | Doc merged; imposed rule banner included; diagrams updated; references to CLI/Console features added. | @@ -304,7 +305,8 @@ > 2025-11-03: DOCS-AIAI-31-002 completed – architecture deep dive documents pipeline, deterministic tooling, caching, profiles, and deployment guidance. | DOCS-AIAI-31-003 | DONE (2025-11-03) | Docs Guild, Advisory AI Guild | AIAI-31-006 | Write `/docs/advisory-ai/api.md` describing endpoints, schemas, errors, rate limits. | API doc aligned with OpenAPI; examples validated; checklist appended. | > 2025-11-03: DOCS-AIAI-31-003 completed – `docs/advisory-ai/api.md` covers scopes, request/response schema, rate limits, error codes, observability, offline notes. -| DOCS-AIAI-31-004 | BLOCKED (2025-11-03) | Docs Guild, Console Guild | CONSOLE-VULN-29-001, CONSOLE-VEX-30-001, EXCITITOR-CONSOLE-23-001 | Create `/docs/advisory-ai/console.md` with screenshots, a11y notes, copy-as-ticket instructions. | Doc merged; images stored; checklist appended. | +| DOCS-AIAI-31-004 | DOING (2025-11-07) | Docs Guild, Console Guild | CONSOLE-VULN-29-001, CONSOLE-VEX-30-001, EXCITITOR-CONSOLE-23-001 | Create `/docs/advisory-ai/console.md` with screenshots, a11y notes, copy-as-ticket instructions. | Doc merged; images stored; checklist appended. | +> 2025-11-07: Draft outline committed; waiting on final console endpoints for screenshots + API captures. > 2025-11-03: BLOCKED – waiting for Console endpoints/widgets (CONSOLE-VULN-29-001, CONSOLE-VEX-30-001, EXCITITOR-CONSOLE-23-001) to land before documenting UI flows. | DOCS-AIAI-31-005 | BLOCKED (2025-11-03) | Docs Guild, DevEx/CLI Guild | CLI-VULN-29-001, CLI-VEX-30-001, AIAI-31-004C | Publish `/docs/advisory-ai/cli.md` covering commands, exit codes, scripting patterns. | Doc merged; examples tested; checklist appended. | > 2025-11-03: BLOCKED – awaiting CLI implementation (`stella advise run`) and golden outputs (CLI-VULN-29-001, CLI-VEX-30-001, AIAI-31-004C). diff --git a/docs/advisory-ai/console.md b/docs/advisory-ai/console.md new file mode 100644 index 000000000..d9f0f2e62 --- /dev/null +++ b/docs/advisory-ai/console.md @@ -0,0 +1,35 @@ +# Advisory AI Console Workflows + +_Last updated: 2025-11-07_ + +This guide documents the forthcoming Advisory AI console experience so that console, docs, and QA guilds share a single reference while the new endpoints finish landing. + +## 1. Entry points & navigation +- **Dashboard tile**: `Advisory AI` card on the console overview routes to `/console/vuln/advisory-ai` once CONSOLE-VULN-29-001 ships. The tile must include the current model build stamp and data freshness time. +- **Deep links**: Copy-as-ticket payloads link back into the console using `/console/vex/{statementId}` (CONSOLE-VEX-30-001). Provide fallbacks that open the Evidence modal with a toast if the workspace is still loading. + +## 2. Evidence surfacing +| Workflow | Required API | Notes | +| --- | --- | --- | +| Findings overview | `GET /console/vuln/findings` | Must include policy verdict badge, VEX justification summary, and last-seen timestamps. | +| Evidence drawer | `GET /console/vex/statements/{id}` | Stream SSE chunk descriptions so long-form provenance renders progressively. | +| Copy as ticket | `POST /console/vuln/tickets` | Returns signed payload + attachment list for JIRA/ServiceNow templates. | + +## 3. Accessibility & offline requirements +- Console screens must pass WCAG 2.2 AA contrast and provide focus order that matches the keyboard shortcuts planned for Advisory AI (see `docs/advisory-ai/overview.md`). +- All screenshots captured for this doc must come from sealed-mode bundles (no external fonts/CDNs). Store them under `docs/assets/advisory-ai/console/` with hashed filenames. +- Modal dialogs need `aria-describedby` attributes referencing the explanation text returned by the API; translation strings must live with existing locale packs. + +## 4. Copy-as-ticket guidance +1. Operators select one or more VEX-backed findings. +2. Console renders the sanitized payload (JSON) plus context summary for the receiving system. +3. Users can download the payload or send it via webhook; both flows must log `console.ticket.export` events for audit. + +## 5. Open items before publication +- [ ] Replace placeholder API responses with captures from the first merged build of CONSOLE-VULN-29-001 / CONSOLE-VEX-30-001. +- [ ] Capture at least two screenshots (list view + evidence drawer) once UI polish is complete. +- [ ] Verify copy-as-ticket instructions with Support to ensure the payload fields align with existing SOC runbooks. + +> Tracking: DOCS-AIAI-31-004 (Docs Guild, Console Guild) + +**Reference**: API contracts and sample payloads live in `docs/api/console/workspaces.md` (see `/console/vuln/*` and `/console/vex/*` sections) plus the JSON fixtures under `docs/api/console/samples/`. diff --git a/docs/api/console/samples/vex-statement-sse.ndjson b/docs/api/console/samples/vex-statement-sse.ndjson new file mode 100644 index 000000000..045d9281d --- /dev/null +++ b/docs/api/console/samples/vex-statement-sse.ndjson @@ -0,0 +1,5 @@ +{"event":"statement.created","data":{"statementId":"vex:tenant-default:jwt-auth:5d1a","advisoryId":"CVE-2024-12345","product":"registry.local/ops/auth:2025.10.0","state":"under_investigation","justification":"exploit_observed","sequence":4178,"updatedAt":"2025-11-07T23:10:09Z"}} +{"event":"statement.updated","data":{"statementId":"vex:tenant-default:jwt-auth:5d1a","advisoryId":"CVE-2024-12345","product":"registry.local/ops/auth:2025.10.0","state":"fixed","justification":"solution_available","sequence":4182,"updatedAt":"2025-11-08T11:44:32Z"}} +{"event":"statement.conflict","data":{"statementId":"vex:tenant-default:jwt-auth:5d1a","advisoryId":"CVE-2024-12345","product":"registry.local/ops/auth:2025.10.0","conflictSummary":"Excititor statement GHSA-1111 differs on status","sequence":4183,"updatedAt":"2025-11-08T11:44:59Z"}} +{"event":"statement.updated","data":{"statementId":"vex:tenant-default:jwt-auth:5d1a","advisoryId":"CVE-2024-12345","product":"registry.local/ops/auth:2025.10.0","state":"fixed","justification":"solution_available","sequence":4184,"updatedAt":"2025-11-08T11:45:04Z"}} +{"event":"statement.deleted","data":{"statementId":"vex:tenant-default:legacy:1a2b","advisoryId":"CVE-2023-9999","product":"registry.local/ops/legacy:2024.01.0","sequence":4185,"updatedAt":"2025-11-08T12:01:01Z"}} diff --git a/docs/api/console/samples/vuln-findings-sample.json b/docs/api/console/samples/vuln-findings-sample.json new file mode 100644 index 000000000..9f7ed1cd3 --- /dev/null +++ b/docs/api/console/samples/vuln-findings-sample.json @@ -0,0 +1,84 @@ +{ + "items": [ + { + "findingId": "tenant-default:advisory-ai:sha256:5d1a", + "coordinates": { + "advisoryId": "CVE-2024-12345", + "package": "pkg:npm/jsonwebtoken@9.0.2", + "component": "jwt-auth-service", + "image": "registry.local/ops/auth:2025.10.0" + }, + "summary": "jsonwebtoken <10.0.0 allows algorithm downgrade.", + "severity": "high", + "cvss": 8.1, + "kev": true, + "policyBadge": "fail", + "vex": { + "statementId": "vex:tenant-default:jwt-auth:5d1a", + "state": "under_investigation", + "justification": "Advisory AI flagged reachable path via Scheduler run 42." + }, + "reachability": { + "status": "reachable", + "lastObserved": "2025-11-07T23:11:04Z", + "signalsVersion": "signals-2025.310.1" + }, + "evidence": { + "sbomDigest": "sha256:6c81f2bbd8bd7336f197f3f68fba2f76d7287dd1a5e2a0f0e9f14f23f3c2f917", + "policyRunId": "policy-run::2025-11-07::ca9f", + "attestationId": "dsse://authority/attest/84a2" + }, + "timestamps": { + "firstSeen": "2025-10-31T04:22:18Z", + "lastSeen": "2025-11-07T23:16:51Z" + } + }, + { + "findingId": "tenant-default:advisory-ai:sha256:9bf4", + "coordinates": { + "advisoryId": "GHSA-xxxx-yyyy-zzzz", + "package": "pkg:docker/library/nginx@1.25.2", + "component": "ingress-gateway", + "image": "registry.local/ops/ingress:2025.09.1" + }, + "summary": "Heap overflow in nginx HTTP/3 parsing.", + "severity": "critical", + "cvss": 9.8, + "kev": false, + "policyBadge": "warn", + "vex": { + "statementId": "vex:tenant-default:ingress:9bf4", + "state": "not_affected", + "justification": "component_not_present" + }, + "reachability": { + "status": "unknown", + "signalsVersion": "signals-2025.309.0" + }, + "evidence": { + "sbomDigest": "sha256:99f1e2a7aa0f7c970dcb6674244f0bfb5f37148e3ee09fd4f925d3358dea2239", + "policyRunId": "policy-run::2025-11-06::b210", + "attestationId": "dsse://authority/attest/1d34" + }, + "timestamps": { + "firstSeen": "2025-10-29T18:03:11Z", + "lastSeen": "2025-11-07T10:45:03Z" + } + } + ], + "facets": { + "severity": [ + { "value": "critical", "count": 1 }, + { "value": "high", "count": 1 } + ], + "policyBadge": [ + { "value": "fail", "count": 1 }, + { "value": "warn", "count": 1 } + ], + "reachability": [ + { "value": "reachable", "count": 1 }, + { "value": "unknown", "count": 1 } + ] + }, + "nextPageToken": "eyJjdXJzb3IiOiJmZjg0NiJ9" +} diff --git a/docs/api/console/workspaces.md b/docs/api/console/workspaces.md new file mode 100644 index 000000000..8ad3030ea --- /dev/null +++ b/docs/api/console/workspaces.md @@ -0,0 +1,311 @@ +# Console Workspaces API + +_Tracking: CONSOLE-VULN-29-001, CONSOLE-VEX-30-001, DOCS-AIAI-31-004_ + +## 1. Goals & Scope + +The console workspaces provide read-only aggregates for Advisory AI operators: + +- `/console/vuln/*` surfaces tenant-scoped findings annotated with policy verdicts, VEX justifications, Scheduler reachability signals, and Advisory AI rationale. +- `/console/vex/*` streams the underlying VEX statements, conflicts, and justification summaries (with SSE support for live updates). + +All endpoints MUST: + +1. Remain deterministic offline (stable sort keys, ISO-8601 UTC timestamps, hashed assets). +2. Operate with Authority-issued DPoP or mTLS client credentials that include `console:read` and either `vuln:read` or `vex:read`. +3. Respect tenant isolation – every request carries `X-StellaOps-Tenant`. + +## 2. Shared Request/Response Conventions + +| Requirement | Description | +| --- | --- | +| Headers | `Authorization: DPoP `, `DPoP: `, `X-StellaOps-Tenant: `, `Accept: application/json` (or `text/event-stream` for SSE). | +| Pagination | Cursor-based via `pageToken`; defaults to 50 items, max 200. Cursors are opaque, base64url, signed. | +| Sorting | Findings sorted by `(severity desc, exploitScore desc, findingId asc)`. Statements sorted by `(lastUpdated desc, statementId asc)`. | +| Dates | RFC 3339 / ISO-8601 UTC (e.g., `2025-11-08T12:02:11Z`). | +| Determinism | All arrays must be pre-sorted; no server-generated uuids in responses. | + +## 3. Vulnerability Workspace (`/console/vuln/*`) + +### 3.1 `GET /console/vuln/findings` + +Query parameters: + +| Parameter | Type | Notes | +| --- | --- | --- | +| `pageToken` | string | Optional cursor from previous response. | +| `pageSize` | int | 1-200, default 50. | +| `severity` | string[] | Accepts `critical`, `high`, `medium`, `low`, `info`. | +| `product` | string[] | SBOM `purl` or image digest anchors. | +| `policyBadge` | string[] | `pass`, `warn`, `fail`, `waived`. | +| `vexState` | string[] | `not_affected`, `fixed`, `under_investigation`, etc. | +| `reachability` | string[] | `reachable`, `unreachable`, `unknown`. | +| `search` | string | Substring match on CVE/GHSA/KEV ID (case-insensitive). | + +Response body: + +```jsonc +{ + "items": [ + { + "findingId": "tenant-default:advisory-ai:sha256:5d1a", + "coordinates": { + "advisoryId": "CVE-2024-12345", + "package": "pkg:npm/jsonwebtoken@9.0.2", + "component": "jwt-auth-service", + "image": "registry.local/ops/auth:2025.10.0" + }, + "summary": "jsonwebtoken <10.0.0 allows algorithm downgrade.", + "severity": "high", + "cvss": 8.1, + "kev": true, + "policyBadge": "fail", + "vex": { + "statementId": "vex:tenant-default:jwt-auth:5d1a", + "state": "under_investigation", + "justification": "Advisory AI flagged reachable path via Scheduler run 42." + }, + "reachability": { + "status": "reachable", + "lastObserved": "2025-11-07T23:11:04Z", + "signalsVersion": "signals-2025.310.1" + }, + "evidence": { + "sbomDigest": "sha256:6c81…", + "policyRunId": "policy-run::2025-11-07::ca9f", + "attestationId": "dsse://authority/attest/84a2" + }, + "timestamps": { + "firstSeen": "2025-10-31T04:22:18Z", + "lastSeen": "2025-11-07T23:16:51Z" + } + } + ], + "facets": { + "severity": [ + { "value": "critical", "count": 2 }, + { "value": "high", "count": 7 } + ], + "policyBadge": [ + { "value": "fail", "count": 6 }, + { "value": "warn", "count": 3 }, + { "value": "waived", "count": 1 } + ], + "reachability": [ + { "value": "reachable", "count": 5 }, + { "value": "unreachable", "count": 2 }, + { "value": "unknown", "count": 1 } + ] + }, + "nextPageToken": "eyJjdXJzb3IiOiJmZjg0NiJ9" +} +``` + +### 3.2 `GET /console/vuln/facets` +Returns the full facet catalog (counts by severity, product, policy badge, VEX state, reachability, KEV flag). Designed for sidebar filters without paging; identical parameter surface as `/findings`. + +### 3.3 `GET /console/vuln/{findingId}` + +Returns the full finding document, including evidence timeline, policy overlays, and export-ready metadata: + +```jsonc +{ + "findingId": "tenant-default:advisory-ai:sha256:5d1a", + "details": { + "description": "jsonwebtoken <10.0.0 allows algorithm downgrade.", + "references": [ + "https://nvd.nist.gov/vuln/detail/CVE-2024-12345", + "https://github.com/auth0/node-jsonwebtoken/security/advisories/GHSA-45mw-4jw3-g2wg" + ], + "exploitAvailability": "known_exploit" + }, + "policyBadges": [ + { + "policyId": "policy://tenant-default/runtime-hardening", + "verdict": "fail", + "explainUrl": "https://console.local/policy/runs/policy-run::2025-11-07::ca9f" + } + ], + "vex": { + "statementId": "vex:tenant-default:jwt-auth:5d1a", + "state": "under_investigation", + "justification": "Runtime telemetry confirmed exploitation path.", + "impactStatement": "Token exchange service remains exposed until patch 2025.11.2.", + "remediations": [ + { + "type": "patch", + "description": "Upgrade jwt-auth-service to 2025.11.2.", + "deadline": "2025-11-12T00:00:00Z" + } + ] + }, + "reachability": { + "status": "reachable", + "callPathSamples": [ + "api-gateway -> jwt-auth-service -> jsonwebtoken.verify" + ], + "lastUpdated": "2025-11-07T23:11:04Z" + }, + "evidence": { + "sbom": { + "digest": "sha256:6c81…", + "componentPath": [ + "/src/jwt-auth/package.json", + "/src/jwt-auth/node_modules/jsonwebtoken" + ] + }, + "attestations": [ + { + "type": "scan-report", + "attestationId": "dsse://authority/attest/84a2", + "signer": "attestor@stella-ops.org", + "bundleDigest": "sha256:e2bb…" + } + ] + }, + "timestamps": { + "firstSeen": "2025-10-31T04:22:18Z", + "lastSeen": "2025-11-07T23:16:51Z", + "vexLastUpdated": "2025-11-07T23:10:09Z" + } +} +``` + +### 3.4 `POST /console/vuln/tickets` + +```jsonc +POST /console/vuln/tickets +{ + "tenant": "tenant-default", + "selection": [ + "tenant-default:advisory-ai:sha256:5d1a", + "tenant-default:advisory-ai:sha256:9bf4" + ], + "targetSystem": "servicenow", + "metadata": { + "assignmentGroup": "runtime-security", + "priority": "P1" + } +} +``` + +Response: + +```jsonc +{ + "ticketId": "console-ticket::tenant-default::2025-11-08::00018", + "payload": { + "version": "2025-11-01", + "tenant": "tenant-default", + "findings": [ + { "findingId": "tenant-default:advisory-ai:sha256:5d1a", "severity": "high" }, + { "findingId": "tenant-default:advisory-ai:sha256:9bf4", "severity": "critical" } + ], + "policyBadge": "fail", + "vexSummary": "2 reachable findings pending patch.", + "attachments": [ + { + "type": "json", + "name": "console-ticket-20251108.json", + "digest": "sha256:1fdd…", + "contentType": "application/json", + "expiresAt": "2025-11-15T00:00:00Z" + } + ] + }, + "auditEventId": "console.ticket.export::2025-11-08::00018" +} +``` + +Requests emit `console.ticket.export` audit events (tenant, user, selection counts, target system). + +## 4. VEX Workspace (`/console/vex/*`) + +### 4.1 `GET /console/vex/statements` + +Parameters mirror `/console/vuln/findings` plus: + +| Parameter | Type | Notes | +| --- | --- | --- | +| `advisoryId` | string[] | CVE/GHSA/OVAL identifiers. | +| `justification` | string[] | `exploit_observed`, `component_not_present`, etc. | +| `statementType` | string[] | `vex`, `openvex`, `custom`, `advisory_ai`. | +| `prefer` | string | `prefer=stream` enables chunked streaming (NDJSON). | + +Response (paged JSON): + +```jsonc +{ + "items": [ + { + "statementId": "vex:tenant-default:jwt-auth:5d1a", + "advisoryId": "CVE-2024-12345", + "product": "registry.local/ops/auth:2025.10.0", + "status": "under_investigation", + "justification": "exploit_observed", + "lastUpdated": "2025-11-07T23:10:09Z", + "source": { + "type": "advisory_ai", + "modelBuild": "aiai-console-2025-10-28", + "confidence": 0.74 + }, + "links": [ + { + "rel": "finding", + "href": "/console/vuln/findings/tenant-default:advisory-ai:sha256:5d1a" + } + ] + } + ], + "nextPageToken": null +} +``` + +When `Accept: text/event-stream`, the endpoint emits events (see §4.3) instead of paged JSON. + +### 4.2 `GET /console/vex/statements/{statementId}` + +Returns the canonical statement plus provenance extracts. SSE clients can call this endpoint when they need full bodies after receiving a summary event. + +### 4.3 `GET /console/vex/events` (SSE) + +Streams live updates for VEX statements affecting the tenant: + +- Event types: `statement.created`, `statement.updated`, `statement.deleted`, `statement.conflict`. +- Fields: `id`, `advisoryId`, `product`, `vexState`, `severityHint`, `policyBadge`, `conflictSummary`, `sequence`. +- Replay: Clients include `Last-Event-ID`; server resumes from sequence. +- Heartbeats every 15 seconds (`event: keepalive`, `data: {}`). + +Example event payload: + +```jsonc +event: statement.updated +data: { + "statementId": "vex:tenant-default:jwt-auth:5d1a", + "advisoryId": "CVE-2024-12345", + "product": "registry.local/ops/auth:2025.10.0", + "state": "fixed", + "justification": "solution_available", + "sequence": 4182, + "updatedAt": "2025-11-08T11:44:32Z" +} +``` + +## 5. Signals & Scheduler Integration + +- Reachability data is materialized by Scheduler delta jobs (`SCHED-CONSOLE-23-001`). `/console/vuln/findings` should cache the most recent job ID and expose `signalsVersion`. +- VEX justification fields reference Excititor statement IDs; ensure the gateway checks Excititor availability and degrades gracefully (returns `state: unavailable` plus telemetry). +- Scheduler must publish `console.vuln.refresh` events whenever advisory/VEX deltas warrant workspace refresh; console SSE endpoint may piggyback on the same Redis/NATS channel. + +## 6. Determinism & Offline Notes + +1. All responses are compressible JSON; no CDN fonts/assets referenced. +2. SSE endpoints must tolerate sealed mode by operating on loopback addresses only. +3. `authority-sealed-ci.json` (see DEVOPS-AIRGAP-57-002) is the evidence Authority consumes before enabling these APIs for sealed tenants; console responses echo `sealed: true/false` flags for UI badges. + +## 7. Sample Payloads for Docs + +- `docs/api/console/samples/vuln-findings-sample.json` – exported via `scripts/generate-console-samples.ts` (placeholder script to be added when backend lands). +- `docs/api/console/samples/vex-statement-sse.ndjson` – contains 5 chronological SSE events for screenshot reproduction. + +> Until backend implementations ship, use the examples above to unblock DOCS-AIAI-31-004; replace them with live captures once the gateway endpoints are available in staging. diff --git a/docs/dev/lnm-determinism-tests.md b/docs/dev/lnm-determinism-tests.md index 120a826e5..ab1ac784c 100644 --- a/docs/dev/lnm-determinism-tests.md +++ b/docs/dev/lnm-determinism-tests.md @@ -18,14 +18,35 @@ - Build linksets from conflicting advisory observations (e.g., differing severity or status flags). - Confirm conflict markers propagate to `AdvisoryLinkset` outputs and associated metrics/log records. - Capture deterministic ordering of conflict explanations for evidence exports. + - Coverage landed via `AdvisoryObservationFactoryTests.Create_PreservesRawReferencesForConflictAudits` (raw linkset + attribute parity) and `AdvisoryEventLogTests.AppendAsync_SortsConflictStatementIds` (canonical conflict JSON + stable hashes). 3. **Evidence/export parity** - Re-run observation/linkset pipelines against identical fixtures and assert resulting evidence manifests hash-identically. - Track monotonic `supersedes` chains and ensure canonical link records include `PRIMARY` schemes. + - `JsonExportSnapshotBuilderTests.WriteAsync_DifferentInputOrderProducesSameDigest` now proves export bundles remain byte-identical regardless of advisory enumeration order; digest sampling extends `ProducesIdenticalBytesAcrossRuns`. + +## Mongo2Go/OpenSSL toolchain + +Concelier solution tests (and most connector suites) depend on Mongo2Go’s embedded `mongod`, which is linked against OpenSSL 1.1. The repo already ships the required libraries in `tests/native/openssl-1.1/linux-x64/{libcrypto.so.1.1,libssl.so.1.1}`; use them instead of installing global packages so offline runners stay deterministic. + +1. Add the shim to your shell before executing any Mongo-backed suite: + + ```bash + export LD_LIBRARY_PATH="$(git rev-parse --show-toplevel)/tests/native/openssl-1.1/linux-x64:${LD_LIBRARY_PATH:-}" + ``` + +2. For single commands you can prefix the invocation (handy for CI copy/paste): + + ```bash + LD_LIBRARY_PATH="$(pwd)/tests/native/openssl-1.1/linux-x64" \ + dotnet test src/Concelier/StellaOps.Concelier.sln --nologo + ``` + +3. The shim’s provenance and troubleshooting notes live in `tests/native/openssl-1.1/README.md`; reference it when mirroring the toolchain into air-gapped runners. ## Migration Steps -- [ ] Retire `StellaOps.Concelier.Merge.Tests` determinism suites once observation/linkset equivalents land. -- [ ] Introduce new regression fixtures under `StellaOps.Concelier.Core.Tests` (shared via `StellaOps.Concelier.Testing`). +- [x] Retire `StellaOps.Concelier.Merge.Tests` determinism suites once observation/linkset equivalents land. +- [x] Introduce new regression fixtures under `StellaOps.Concelier.Core.Tests` (shared via `StellaOps.Concelier.Testing`). - [ ] Wire test helpers to Mongo in-memory harness for end-to-end parity runs. - [ ] Update documentation (`docs/migration/no-merge.md`) with validation checklist once new tests are green. diff --git a/docs/implplan/SPRINTS.md b/docs/implplan/SPRINTS.md index 6e5d3485a..6c087355f 100644 --- a/docs/implplan/SPRINTS.md +++ b/docs/implplan/SPRINTS.md @@ -44,6 +44,7 @@ Follow the sprint files below in order. Update task status in both `SPRINTS` and > 2025-11-03: MERGE-LNM-21-001 marked DONE – published `docs/migration/no-merge.md` with rollout, backfill, validation, and rollback guidance for the LNM cutover. > 2025-11-04: GRAPH-INDEX-28-011 marked DONE (Graph Indexer Guild) – SBOM ingest DI wiring now emits graph snapshots by default, snapshot root configurable via `STELLAOPS_GRAPH_SNAPSHOT_DIR`, and Graph Indexer tests exercised with Mongo URI guidance. > 2025-11-06: MERGE-LNM-21-002 remains DOING (BE-Merge) – default-off merge DI + job gating landed, but Concelier WebService ingest/mirror tests are failing; guard and migration fixes pending before completion. +> 2025-11-07: MERGE-LNM-21-002 marked DONE (BE-Merge) – Link-Not-Merge telemetry gaps closed by introducing `StellaOps.Ingestion.Telemetry`, guard metrics/tests updated, and Concelier Exporter JSON + solution smoke suites re-run to cover the new filename normalization. > 2025-11-06: TASKRUN-43-001 marked DONE (Task Runner Guild) – approvals resume API now requeues packs, plan snapshots persisted, and filesystem artifact uploader stores manifests/files for offline review. > 2025-11-06: CLI-POLICY-23-005 marked DONE (DevEx/CLI Guild) – policy activate CLI verifies scheduling/approval flow, Spectre console fallbacks emit warnings offline, and full CLI suite passes against local feeds. > 2025-11-07: DOCS-AIAI-31-007 marked DONE (Docs Guild, Security Guild) – published `/docs/security/assistant-guardrails.md` covering redaction rules, blocked phrases, telemetry, and alert wiring. diff --git a/docs/implplan/SPRINT_100_identity_signing.md b/docs/implplan/SPRINT_100_identity_signing.md index a05d59d75..ce3ba1419 100644 --- a/docs/implplan/SPRINT_100_identity_signing.md +++ b/docs/implplan/SPRINT_100_identity_signing.md @@ -18,7 +18,8 @@ ATTEST-VERIFY-74-001 | DONE | Emit telemetry (spans/metrics) tagged by subject, ATTEST-VERIFY-74-002 | DONE (2025-11-01) | Document verification report schema and explainability in `/docs/modules/attestor/workflows.md`. Dependencies: ATTEST-VERIFY-73-001. | Verification Guild, Docs Guild (src/Attestor/StellaOps.Attestor.Verify/TASKS.md) ATTESTOR-72-001 | DONE | Scaffold service (REST API skeleton, storage interfaces, KMS integration stubs) and DSSE validation pipeline. Dependencies: ATTEST-ENVELOPE-72-001. | Attestor Service Guild (src/Attestor/StellaOps.Attestor/TASKS.md) ATTESTOR-72-002 | DONE | Implement attestation store (DB tables, object storage integration), CRUD, and indexing strategies. Dependencies: ATTESTOR-72-001. | Attestor Service Guild (src/Attestor/StellaOps.Attestor/TASKS.md) -ATTESTOR-72-003 | BLOCKED | Validate attestation store TTL against production-like Mongo/Redis stack; capture logs and remediation plan. Dependencies: ATTESTOR-72-002. | Attestor Service Guild, QA Guild (src/Attestor/StellaOps.Attestor/TASKS.md) +ATTESTOR-72-003 | DONE (2025-11-03) | Validate attestation store TTL against production-like Mongo/Redis stack; capture logs and remediation plan. Dependencies: ATTESTOR-72-002. | Attestor Service Guild, QA Guild (src/Attestor/StellaOps.Attestor/TASKS.md) +> 2025-11-03: TTL soak tests captured in `docs/modules/attestor/ttl-validation.md`; Mongo/Redis evidence archived for replay. ATTESTOR-73-001 | DONE (2025-11-01) | Implement signing endpoint with Ed25519/ECDSA support, KMS integration, and audit logging. Dependencies: ATTESTOR-72-002, KMS-72-001. | Attestor Service Guild, KMS Guild (src/Attestor/StellaOps.Attestor/TASKS.md) @@ -48,8 +49,11 @@ AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Provision new scopes (`airgap:seal`, `a > 2025-11-04: Verified discovery metadata now advertises the airgap scope trio, `etc/authority.yaml.sample` + offline kit docs ship the new roles, and Authority tests enforce tenant gating for `airgap:*` scopes (`dotnet test` executed). AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. Dependencies: AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) > 2025-11-04: `/authority/audit/airgap` minimal APIs persist tenant-scoped records with paging, RBAC checks for `airgap:import`/`airgap:status:read` pass, and Authority integration suite (187 tests) exercised the audit flow. -AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. Dependencies: AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002. | Authority Core & Security Guild, DevOps Guild (src/Authority/StellaOps.Authority/TASKS.md) +AUTH-AIRGAP-57-001 | DOING (2025-11-08) | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. Dependencies: AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002. | Authority Core & Security Guild, DevOps Guild (src/Authority/StellaOps.Authority/TASKS.md) > 2025-11-01: AUTH-AIRGAP-57-001 blocked pending definition of sealed-confirmation evidence and configuration shape before gating (Authority Core & Security Guild, DevOps Guild). +> 2025-11-08: Flipped to DOING; partnering with DevOps on artifacts so Authority gating tests can consume sealed confirmations once published (target 2025-11-10). +> 2025-11-07: Still waiting on DEVOPS-AIRGAP-57-002 sealed-mode CI suite (`ops/devops/sealed-mode-ci/*`) to publish artefacts so Authority can wire the gating tests. +> 2025-11-08: DevOps sealed-mode CI now uploads `artifacts/sealed-mode-ci//authority-sealed-ci.json`; Authority to hook the gating middleware/tests up to that feed next. AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) > 2025-11-01: AUTH-NOTIFY-38-001 completed—Notify scope catalog, discovery metadata, docs, configuration samples, and service tests updated for new roles. AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. Dependencies: AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) @@ -73,6 +77,7 @@ AUTH-PACKS-41-001 | DONE (2025-11-04) | Define CLI SSO profiles and pack scopes > 2025-11-04: Verified discovery metadata, OpenAPI, `etc/authority.yaml.sample`, and offline kit docs reflect the packs scope set; Authority suite re-run (`dotnet test`) to confirm tenant gating and policy checks. > 2025-11-02: Shared OpenSSL 1.1 shim now feeds Mongo2Go for Authority & Signals tests, keeping pack scope regressions and other Mongo flows working on OpenSSL 3 hosts. AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. Dependencies: AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) +> 2025-11-07: AUTH-PACKS-41-001 + TASKRUN-42-001 are DONE; remaining blocker is ORCH-SVC-42-101 (still TODO) for log streaming/approvals APIs. Not deleted—waiting on Orchestrator to publish contracts. [Identity & Signing] 100.B) Authority.II @@ -80,8 +85,17 @@ Depends on: Sprint 100.B - Authority.I Summary: Identity & Signing focus on Authority (phase II). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- -AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. Dependencies: AUTH-POLICY-23-001. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) -AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. Dependencies: AUTH-POLICY-23-001. | Authority Core & Docs Guild (src/Authority/StellaOps.Authority/TASKS.md) +AUTH-POLICY-23-001 | DONE (2025-10-27) | Introduce fine-grained policy scopes (`policy:read`, `policy:author`, `policy:review`, `policy:simulate`, `findings:read`) for CLI/service accounts; update discovery metadata, issuer templates, and offline defaults. Dependencies: AUTH-AOC-19-002. | Authority Core & Docs Guild (src/Authority/StellaOps.Authority/TASKS.md) +AUTH-POLICY-23-002 | DONE (2025-11-08) | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. Dependencies: AUTH-POLICY-23-001. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) +> 2025-11-08: Added Policy Engine activation options (force/default/audit toggles), enforced pending-second-approval responses, and emitted `policy.activation.*` telemetry across auditor logs. +AUTH-POLICY-23-003 | DONE (2025-11-08) | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. Dependencies: AUTH-POLICY-23-001. | Authority Core & Docs Guild (src/Authority/StellaOps.Authority/TASKS.md) +> 2025-11-08: Documented dual-control activation steps, new `PolicyEngine.activation.*` knobs, sample YAML defaults, and console/operator guidance for audit visibility. +> 2025-11-07: Scope migration (AUTH-POLICY-23-001) shipped; activation guardrail and documentation updates now waiting on pairing. +AUTH-DPOP-11-001 | DOING (2025-11-07) | Enforce DPoP sender constraints for `/token` flows (nonce policies, JKT persistence, structured telemetry) so downstream services can trust `cnf` metadata. Dependencies: AUTH-AOC-19-002. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) +AUTH-MTLS-11-002 | DOING (2025-11-07) | Deliver mTLS-bound token issuance/validation (cert thumbprint storage, JWKS rotation hooks) required for high-assurance tenants and plugin mitigations. Dependencies: AUTH-DPOP-11-001. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) +> 2025-11-07: Authority + DevOps stand-up aligned on a 2025-11-10 delivery target for AUTH-DPOP-11-001 / AUTH-MTLS-11-002 and DEVOPS-AIRGAP-57-002 so plugin security/air-gap gating can flip to DOING immediately after. +> 2025-11-08: Taking ownership to wire certificate thumbprint persistence + audit logging; blocking issues from AUTH-DPOP-11-001 now resolved, so mTLS enforcement can proceed. +> 2025-11-08: `/token`/`/introspect` now enforce TLS certificate matches for mTLS-bound tokens and emit `authority_mtls_mismatch_total` telemetry when rejections occur. AUTH-POLICY-27-002 | DONE (2025-11-02) | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. Dependencies: AUTH-POLICY-27-001, REGISTRY-API-27-007. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) > 2025-11-02: Added interactive-only `policy:publish`/`policy:promote` scopes with metadata requirements (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth validation, audit enrichment, and updated config/docs for operators. AUTH-POLICY-27-003 | DONE (2025-11-04) | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. Dependencies: AUTH-POLICY-27-001, AUTH-POLICY-27-002. | Authority Core & Docs Guild (src/Authority/StellaOps.Authority/TASKS.md) @@ -100,9 +114,11 @@ AUTH-VULN-29-003 | DONE (2025-11-04) | Update security docs/config samples for V PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | BE-Auth Plugin, Docs Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) PLG6.DIAGRAM | TODO | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | Docs Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) PLG7.RFC | REVIEW | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | BE-Auth Plugin, Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) -SEC2.PLG | BLOCKED (2025-10-21) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`.
⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 to stabilise Authority auth surfaces before final verification + publish. | Security Guild, Storage Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) -SEC3.PLG | BLOCKED (2025-10-21) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after).
⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 so limiter telemetry contract matches final authority surface. | Security Guild, BE-Auth Plugin (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) -SEC5.PLG | BLOCKED (2025-10-21) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog.
⛔ Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) +SEC2.PLG | BLOCKED (2025-10-21) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`.
⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 to stabilise Authority auth surfaces (PLUGIN-DI-08-001 closed 2025-10-21; re-run once sender constraints land). | Security Guild, Storage Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) +SEC3.PLG | BLOCKED (2025-10-21) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after).
⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 already merged, so limiter telemetry just awaits final Authority surface. | Security Guild, BE-Auth Plugin (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) +SEC5.PLG | BLOCKED (2025-10-21) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog.
⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 (PLUGIN-DI-08-001 landed 2025-10-21). | Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) +> 2025-11-07: Upstream AUTH-DPOP-11-001 / AUTH-MTLS-11-002 now DOING; revisit plugin backlog once sender-constraint hardening lands. +> 2025-11-08: Dependency audit confirmed — AUTH-DPOP-11-001 / AUTH-MTLS-11-002 staffed with 2025-11-10 delivery; no missing SEC2/SEC3/SEC5 subtasks, so these remain BLOCKED only until sender constraints merge. PLG7.IMPL-001 | DONE (2025-11-03) | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | BE-Auth Plugin (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) > 2025-11-03: Initial `StellaOps.Authority.Plugin.Ldap` project/tests scaffolded with configuration options + registrar; sample manifest (`etc/authority.plugins/ldap.yaml`) updated to new schema (client certificate, trust store, insecure toggle). PLG7.IMPL-002 | DONE (2025-11-04) | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | BE-Auth Plugin, Security Guild (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md) @@ -131,7 +147,14 @@ KMS-73-001 | TODO | Add cloud KMS driver (e.g., AWS KMS, GCP KMS) with signing a KMS-73-002 | TODO | Implement PKCS#11/HSM driver plus FIDO2 signing support for high assurance workflows. Dependencies: KMS-73-001. | KMS Guild (src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md) +[Identity & Signing] 100.E) Deployment +Depends on: Helm base chart scaffolding (HELM-45-001) +Summary: Wire deployment assets so Policy Engine activation guardrails stay deterministic across clusters/offline kits. +Task ID | State | Task description | Owners (Source) +--- | --- | --- | --- +HELM-45-004 | DONE (2025-11-08) | Mount the new `policy-engine-activation` ConfigMap into the Policy Engine (and Policy Gateway) pods, ensure runtime config loads activation overrides from env/file, and refresh Helm/Compose samples for offline parity. | Deployment Guild, Policy Guild (ops/deployment/TASKS.md) + +> 2025-11-08: Helm template now injects the activation ConfigMap for policy-engine/gateway pods, Policy Engine host loads `/config/policy-engine/activation.yaml`, Policy Engine/Gateway tests are green, and CI now runs `helm lint`/`helm template` over every `values*.yaml`. + + If all tasks are done - read next sprint section - SPRINT_110_ingestion_evidence.md - - - diff --git a/docs/implplan/SPRINT_110_ingestion_evidence.md b/docs/implplan/SPRINT_110_ingestion_evidence.md index 30de6d553..c6ec876d5 100644 --- a/docs/implplan/SPRINT_110_ingestion_evidence.md +++ b/docs/implplan/SPRINT_110_ingestion_evidence.md @@ -34,7 +34,9 @@ AIAI-31-004C | DONE (2025-11-06) | Deliver CLI `stella advise run` command, rend DOCS-AIAI-31-002 | DONE (2025-11-03) | Author `/docs/advisory-ai/architecture.md` detailing RAG pipeline, deterministic tooling, caching, model profiles. Dependencies: AIAI-31-004. | Docs Guild, Advisory AI Guild (docs/TASKS.md) DOCS-AIAI-31-001 | DONE (2025-11-03) | Publish `/docs/advisory-ai/overview.md` covering capabilities, guardrails, RBAC personas, and offline posture. | Docs Guild, Advisory AI Guild (docs/TASKS.md) DOCS-AIAI-31-003 | DONE (2025-11-03) | Write `/docs/advisory-ai/api.md` covering endpoints, schemas, errors, rate limits, and imposed-rule banner. Dependencies: DOCS-AIAI-31-002. | Docs Guild, Advisory AI Guild (docs/TASKS.md) -DOCS-AIAI-31-004 | BLOCKED (2025-11-03) | Create `/docs/advisory-ai/console.md` with screenshots, a11y notes, copy-as-ticket instructions. Dependencies: CONSOLE-VULN-29-001, CONSOLE-VEX-30-001, EXCITITOR-CONSOLE-23-001. | Docs Guild, Console Guild (docs/TASKS.md) +DOCS-AIAI-31-004 | DOING (2025-11-07) | Create `/docs/advisory-ai/console.md` with screenshots, a11y notes, copy-as-ticket instructions. Dependencies: CONSOLE-VULN-29-001, CONSOLE-VEX-30-001, EXCITITOR-CONSOLE-23-001. | Docs Guild, Console Guild (docs/TASKS.md) +> 2025-11-07: Draft doc committed (`docs/advisory-ai/console.md`) with workflow outline; screenshots will be added once CONSOLE-VULN-29-001 / CONSOLE-VEX-30-001 ship. +> 2025-11-08: Console endpoints are staffed (CONSOLE-VULN-29-001 / CONSOLE-VEX-30-001 DOING); still waiting on EXCITITOR-CONSOLE-23-001 feeds before capturing screenshots/tests. DOCS-AIAI-31-005 | BLOCKED (2025-11-03) | Publish `/docs/advisory-ai/cli.md` covering commands, exit codes, scripting patterns. Dependencies: CLI-VULN-29-001, CLI-VEX-30-001, AIAI-31-004C. | Docs Guild, DevEx/CLI Guild (docs/TASKS.md) DOCS-AIAI-31-006 | BLOCKED (2025-11-03) | Update `/docs/policy/assistant-parameters.md` covering temperature, token limits, ranking weights, TTLs. Dependencies: POLICY-ENGINE-31-001. | Docs Guild, Policy Guild (docs/TASKS.md) DOCS-AIAI-31-007 | DONE (2025-11-07) | Write `/docs/security/assistant-guardrails.md` detailing redaction, injection defense, logging. Dependencies: AIAI-31-005. | Docs Guild, Security Guild (docs/TASKS.md) @@ -127,7 +129,7 @@ CONCELIER-OAS-61-001 `Spec coverage` | TODO | Update Concelier OAS with advisory CONCELIER-OAS-61-002 `Examples library` | TODO | Provide rich examples for advisories, linksets, conflict annotations used by SDK + docs. Dependencies: CONCELIER-OAS-61-001. | Concelier Core Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) CONCELIER-OAS-62-001 `SDK smoke tests` | TODO | Add SDK tests covering advisory search, pagination, and conflict handling; ensure source metadata surfaced. Dependencies: CONCELIER-OAS-61-002. | Concelier Core Guild, SDK Generator Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) CONCELIER-OAS-63-001 `Deprecation headers` | TODO | Implement deprecation header support and timeline events for retiring endpoints. Dependencies: CONCELIER-OAS-62-001. | Concelier Core Guild, API Governance Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) -CONCELIER-OBS-50-001 `Telemetry adoption` | TODO | Replace ad-hoc logging with telemetry core across ingestion/linking pipelines; ensure spans/logs include tenant, source vendor, upstream id, content hash, and trace IDs. | Concelier Core Guild, Observability Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) +CONCELIER-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Replace ad-hoc logging with telemetry core across ingestion/linking pipelines; ensure spans/logs include tenant, source vendor, upstream id, content hash, and trace IDs. | Concelier Core Guild, Observability Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) CONCELIER-OBS-51-001 `Metrics & SLOs` | TODO | Emit metrics for ingest latency (cold/warm), queue depth, aoc violation rate, and publish SLO burn-rate alerts (ingest P95 <30s cold / <5s warm). Ship dashboards + alert configs. Dependencies: CONCELIER-OBS-50-001. | Concelier Core Guild, DevOps Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) CONCELIER-OBS-52-001 `Timeline events` | TODO | Emit `timeline_event` records for advisory ingest/normalization/linkset creation with provenance, trace IDs, conflict summaries, and evidence placeholders. Dependencies: CONCELIER-OBS-51-001. | Concelier Core Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) CONCELIER-OBS-53-001 `Evidence snapshots` | TODO | Produce advisory evaluation bundle payloads (raw doc, linkset, normalization diff) for evidence locker; ensure Merkle manifests seeded with content hashes. Dependencies: CONCELIER-OBS-52-001. | Concelier Core Guild, Evidence Locker Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) @@ -172,14 +174,17 @@ CONCELIER-WEB-AIRGAP-56-001 `Mirror import APIs` | TODO | Extend ingestion endpo CONCELIER-WEB-AIRGAP-56-002 `Airgap status surfaces` | TODO | Add staleness metadata and bundle provenance to advisory APIs (`/advisories/observations`, `/advisories/linksets`). Dependencies: CONCELIER-WEB-AIRGAP-56-001. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-AIRGAP-57-001 `Error remediation` | TODO | Map sealed-mode violations to `AIRGAP_EGRESS_BLOCKED` responses with user guidance. Dependencies: CONCELIER-WEB-AIRGAP-56-002. | Concelier WebService Guild, AirGap Policy Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-AIRGAP-58-001 `Import timeline emission` | TODO | Emit timeline events for bundle ingestion operations with bundle ID, scope, and actor metadata. Dependencies: CONCELIER-WEB-AIRGAP-57-001. | Concelier WebService Guild, AirGap Importer Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) -CONCELIER-WEB-AOC-19-002 `AOC observability` | TODO | Emit `ingestion_write_total`, `aoc_violation_total`, latency histograms, and tracing spans (`ingest.fetch/transform/write`, `aoc.guard`). Wire structured logging to include tenant, source vendor, upstream id, and content hash. | Concelier WebService Guild, Observability Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) +CONCELIER-WEB-AOC-19-002 `AOC observability` | DONE (2025-11-07) | Emit `ingestion_write_total`, `aoc_violation_total`, latency histograms, and tracing spans (`ingest.fetch/transform/write`, `aoc.guard`). Wire structured logging to include tenant, source vendor, upstream id, and content hash. | Concelier WebService Guild, Observability Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-AOC-19-003 `Schema/guard unit tests` | TODO | Add unit tests covering schema validation failures, forbidden field rejections (`ERR_AOC_001/002/006/007`), idempotent upserts, and supersedes chains using deterministic fixtures. Dependencies: CONCELIER-WEB-AOC-19-002. | QA Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-AOC-19-004 `End-to-end ingest verification` | TODO | Create integration tests ingesting large advisory batches (cold/warm) validating linkset enrichment, metrics emission, and reproducible outputs. Capture load-test scripts + doc notes for Offline Kit dry runs. Dependencies: CONCELIER-WEB-AOC-19-003. | Concelier WebService Guild, QA Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) +CONCELIER-WEB-AOC-19-005 `Chunk evidence regression` | TODO (2025-11-08) | Fix `/advisories/{key}/chunks` fixture seeding so AdvisoryChunksEndpoint tests stop returning 404/not-found when raw documents are pre-populated; ensure the Mongo migration no longer emits “Unable to locate advisory_raw documents” during WebService test boot. Dependencies: CONCELIER-WEB-AOC-19-002. | Concelier WebService Guild, QA Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) +CONCELIER-WEB-AOC-19-006 `Allowlist ingest auth parity` | TODO (2025-11-08) | Align WebService auth defaults with the test tokens so the allowlisted tenant can create an advisory before forbidden tenants are rejected in `AdvisoryIngestEndpoint_RejectsTenantOutsideAllowlist`. Dependencies: CONCELIER-WEB-AOC-19-002. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) +CONCELIER-WEB-AOC-19-007 `AOC verify violation codes` | TODO (2025-11-08) | Update AOC verify logic/fixtures so guard failures produce the expected `ERR_AOC_001` payload (current regression returns `ERR_AOC_004`) while keeping mapper/guard parity exercised by the new tests. Dependencies: CONCELIER-WEB-AOC-19-002. | Concelier WebService Guild, QA Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-OAS-61-001 `/.well-known/openapi` | DONE (2025-11-02) | Implement discovery endpoint emitting Concelier spec with version metadata and ETag. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-OAS-61-002 `Error envelope migration` | TODO | Ensure all API responses use standardized error envelope; update controllers/tests. Dependencies: CONCELIER-WEB-OAS-61-001. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-OAS-62-001 `Examples expansion` | TODO | Add curated examples for advisory observations/linksets/conflicts; integrate into dev portal. Dependencies: CONCELIER-WEB-OAS-61-002. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-OAS-63-001 `Deprecation headers` | TODO | Add Sunset/Deprecation headers for retiring endpoints and update documentation/notifications. Dependencies: CONCELIER-WEB-OAS-62-001. | Concelier WebService Guild, API Governance Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) -CONCELIER-WEB-OBS-50-001 `Telemetry adoption` | TODO | Adopt telemetry core in web service host, ensure ingest + read endpoints emit trace/log fields (`tenant_id`, `route`, `decision_effect`), and add correlation IDs to responses. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) +CONCELIER-WEB-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Adopt telemetry core in web service host, ensure ingest + read endpoints emit trace/log fields (`tenant_id`, `route`, `decision_effect`), and add correlation IDs to responses. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-OBS-51-001 `Observability APIs` | TODO | Surface ingest health metrics, queue depth, and SLO status via `/obs/concelier/health` endpoint for Console widgets, with caching and tenant partitioning. Dependencies: CONCELIER-WEB-OBS-50-001. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-WEB-OBS-52-001 `Timeline streaming` | TODO | Provide SSE stream `/obs/concelier/timeline` bridging to Timeline Indexer with paging tokens, guardrails, and audit logging. Dependencies: CONCELIER-WEB-OBS-51-001. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) @@ -194,7 +199,7 @@ CONCELIER-WEB-OBS-54-001 `Attestation exposure` | TODO | Provide `/attestations/ CONCELIER-WEB-OBS-55-001 `Incident mode toggles` | TODO | Implement incident mode toggle endpoints, propagate to orchestrator/locker, and document cooldown/backoff semantics. Dependencies: CONCELIER-WEB-OBS-54-001. | Concelier WebService Guild, DevOps Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) FEEDCONN-CCCS-02-009 Version range provenance (Oct 2025) | BE-Conn-CCCS | **TODO (due 2025-10-21)** – Map CCCS advisories into the new `advisory_observations.affected.versions[]` structure, preserving each upstream range with provenance anchors (`cccs:{serial}:{index}`) and normalized comparison keys. Update mapper tests/fixtures for the Link-Not-Merge schema and verify linkset builders consume the ranges without relying on legacy merge counters.
2025-10-29: `docs/dev/normalized-rule-recipes.md` now documents helper snippets for building observation version entries—use them instead of merge-specific builders and refresh fixtures with `UPDATE_CCCS_FIXTURES=1`. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/TASKS.md) FEEDCONN-CERTBUND-02-010 Version range provenance | BE-Conn-CERTBUND | **TODO (due 2025-10-22)** – Translate `product.Versions` phrases (e.g., `2023.1 bis 2024.2`, `alle`) into comparison helpers for `advisory_observations.affected.versions[]`, capturing provenance (`certbund:{advisoryId}:{vendor}`) and localisation notes. Update mapper/tests for the Link-Not-Merge schema and refresh documentation accordingly. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.CertBund/TASKS.md) -FEEDCONN-CISCO-02-009 SemVer range provenance | BE-Conn-Cisco | **TODO (due 2025-10-21)** – Emit Cisco SemVer ranges into `advisory_observations.affected.versions[]` with provenance identifiers (`cisco:{productId}`) and deterministic comparison keys. Update mapper/tests for the Link-Not-Merge schema and replace legacy merge counter checks with observation/linkset validation. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/TASKS.md) +FEEDCONN-CISCO-02-009 SemVer range provenance | BE-Conn-Cisco | **DOING (2025-11-08)** – Emitting Cisco SemVer ranges into `advisory_observations.affected.versions[]` with provenance identifiers (`cisco:{productId}`) and deterministic comparison keys. Updating mapper/tests for the Link-Not-Merge schema and replacing legacy merge counter checks with observation/linkset validation. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/TASKS.md) FEEDCONN-ICSCISA-02-012 Version range provenance | BE-Conn-ICS-CISA | **DONE (2025-11-03)** – Promote existing firmware/semver data into `advisory_observations.affected.versions[]` entries with deterministic comparison keys and provenance identifiers (`ics-cisa:{advisoryId}:{product}`). Add regression coverage for mixed firmware strings and raise a Models ticket only when observation schema needs a new comparison helper.
2025-10-29: Follow `docs/dev/normalized-rule-recipes.md` §2 to build observation version entries and log failures without invoking the retired merge helpers.
2025-11-03: Completed – connector now normalizes semver ranges with provenance notes, RSS fallback content clears the AOC guard, and end-to-end Fetch/Parse/Map integration tests pass. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ics.Cisa/TASKS.md) FEEDCONN-KISA-02-008 Firmware range provenance | BE-Conn-KISA, Models | **DONE (2025-11-04)** – Define comparison helpers for Hangul-labelled firmware ranges (`XFU 1.0.1.0084 ~ 2.0.1.0034`) and map them into `advisory_observations.affected.versions[]` with provenance tags. Coordinate with Models only if a new comparison scheme is required, then update localisation notes and fixtures for the Link-Not-Merge schema.
2025-11-03: Analysis in progress – auditing existing mapper output/fixtures ahead of implementing firmware range normalization and provenance wiring.
2025-11-03: SemVer normalization helper wired through `KisaMapper` with provenance slugs + vendor extensions; integration tests updated and green, follow-up capture for additional Hangul exclusivity markers queued before completion.
2025-11-03: Extended connector tests to cover single-ended (`이상`, `초과`, `이하`, `미만`) and non-numeric phrases, verifying normalized rule types (`gt`, `gte`, `lt`, `lte`) and fallback behaviour; broader corpus review remains before transitioning to DONE.
2025-11-03: Captured the top 10 `detailDos.do?IDX=` pages into `seed-data/kisa/html/` via `scripts/kisa_capture_html.py`; JSON endpoint (`rssDetailData.do?IDX=…`) now returns error pages, so connector updates must parse the embedded HTML or secure authenticated API access before closing.
2025-11-04: Fetch + parse pipeline now consumes the HTML detail pages end to end (metadata persisted, DOM parser extracts vendor/product ranges); fixtures/tests operate on the HTML snapshots to guard normalized SemVer + vendor extension expectations and severity extraction. | CONCELIER-LNM-21-001 (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Kisa/TASKS.md) FEEDCONN-SHARED-STATE-003 Source state seeding helper | Tools Guild, BE-Conn-MSRC | **DONE (2025-11-04)** – Delivered `SourceStateSeeder` CLI + processor APIs, Mongo fixtures, and MSRC runbook updates. Seeds raw docs + cursor state deterministically; tests cover happy/path/idempotent flows (`dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.Common.Tests/...` – note: requires `libcrypto.so.1.1` when running Mongo2Go locally). | Tools (src/Concelier/__Libraries/StellaOps.Concelier.Connector.Common/TASKS.md) @@ -212,7 +217,7 @@ Summary: Ingestion & Evidence focus on Concelier (phase VII). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- MERGE-LNM-21-002 | DONE (2025-11-07) | Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.
2025-11-03: Began dependency audit and call-site inventory ahead of deprecation plan; cataloging service registrations/tests referencing merge APIs.
2025-11-05 14:42Z: Drafted `concelier:features:noMergeEnabled` gating, merge job allowlist handling, and deprecation/telemetry changes prior to analyzer rollout.
2025-11-06 16:10Z: Landed analyzer project (`CONCELIER0002`), wired into Concelier WebService/tests, and updated docs to direct suppressions through explicit migration notes.
2025-11-07 03:25Z: Default-on toggle + job gating surfaced ingestion test brittleness; guard/migration diagnostics capture requests missing `upstream.contentHash`.
2025-11-07 19:45Z: Set `ConcelierOptions.Features.NoMergeEnabled` default to `true`, added regression coverage (`Features_NoMergeEnabled_DefaultsToTrue`), and rechecked ingest helpers to carry canonical links. Remote .NET 10 CLI run remains queued for validation. | BE-Merge (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) -MERGE-LNM-21-003 Determinism/test updates | DOING (2025-11-07) | QA Guild, BE-Merge | Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible. Dependencies: MERGE-LNM-21-002.
2025-11-07: Drafting test migration plan (`docs/dev/lnm-determinism-tests.md`) to map legacy merge fixtures onto observation/linkset pipelines; identifying coverage gaps (conflict surfacing, raw vs canonical parity, hash stability).
2025-11-07 20:05Z: Landed `AdvisoryObservationFactoryTests.Create_IsDeterministicAcrossRuns` to cover canonical JSON stability and pruned the old merge determinism integration test. | MERGE-LNM-21-002 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) +MERGE-LNM-21-003 Determinism/test updates | DONE (2025-11-07) | QA Guild, BE-Merge | Replaced the retired merge determinism harness with observation/linkset/export regressions. `AdvisoryObservationFactoryTests` now assert raw reference parity + conflict notes, `AdvisoryEventLogTests` sort/uniquify conflict statement IDs, and `JsonExportSnapshotBuilderTests` guard digest parity across reordered input. `docs/dev/lnm-determinism-tests.md` checklist updated with the new coverage. | MERGE-LNM-21-002 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) WEB-AOC-19-001 (dependency) | DONE (2025-11-07) | Shared guard primitives now enforce the top-level allowlist (`_id`, tenant, source, upstream, content, identifiers, linkset, supersedes, created/ingested timestamps, attributes) and emit the reusable `AocError` payload consumed by HTTP/CLI tooling. Extend `AocGuardOptions.AllowedTopLevelFields` when staging new schema fields to avoid false-positive `ERR_AOC_007` violations. | BE-Base Platform Guild (docs/aoc/guard-library.md, src/Web/StellaOps.Web/TASKS.md) @@ -278,7 +283,7 @@ EXCITITOR-OAS-61-001 `Spec coverage` | TODO | Update VEX OAS to include observat EXCITITOR-OAS-61-002 `Example catalog` | TODO | Provide examples for VEX justifications, statuses, conflicts; ensure SDK docs reference them. Dependencies: EXCITITOR-OAS-61-001. | Excititor Core Guild (src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md) EXCITITOR-OAS-62-001 `SDK smoke tests` | TODO | Add SDK scenarios for VEX observation queries and conflict handling to language smoke suites. Dependencies: EXCITITOR-OAS-61-002. | Excititor Core Guild, SDK Generator Guild (src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md) EXCITITOR-OAS-63-001 `Deprecation headers` | TODO | Add deprecation metadata and notifications for legacy VEX routes. Dependencies: EXCITITOR-OAS-62-001. | Excititor Core Guild, API Governance Guild (src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md) -EXCITITOR-OBS-50-001 `Telemetry adoption` | TODO | Integrate telemetry core across VEX ingestion/linking, ensuring spans/logs capture tenant, product scope, upstream id, justification hash, and trace IDs. | Excititor Core Guild, Observability Guild (src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md) +EXCITITOR-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Integrate telemetry core across VEX ingestion/linking, ensuring spans/logs capture tenant, product scope, upstream id, justification hash, and trace IDs. | Excititor Core Guild, Observability Guild (src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md) EXCITITOR-OBS-51-001 `Metrics & SLOs` | TODO | Publish metrics for VEX ingest latency, scope resolution success, conflict rate, signature verification failures. Define SLOs (link latency P95 <30s) and configure burn-rate alerts. Dependencies: EXCITITOR-OBS-50-001. | Excititor Core Guild, DevOps Guild (src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md) @@ -332,16 +337,16 @@ Summary: Ingestion & Evidence focus on Excititor (phase VI). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- EXCITITOR-WEB-AIRGAP-58-001 | TODO | Emit timeline events for VEX bundle imports with bundle ID, scope, and actor metadata. Dependencies: EXCITITOR-WEB-AIRGAP-57-001. | Excititor WebService Guild, AirGap Importer Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) -EXCITITOR-WEB-AOC-19-001 `Raw VEX ingestion APIs` | TODO | Implement `POST /ingest/vex`, `GET /vex/raw*`, and `POST /aoc/verify` endpoints. Enforce Authority scopes, tenant injection, and guard pipeline to ensure only immutable VEX facts are persisted. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) -EXCITITOR-WEB-AOC-19-002 `AOC observability + metrics` | TODO | Export metrics (`ingestion_write_total`, `aoc_violation_total`, signature verification counters) and tracing spans matching Conseiller naming. Ensure structured logging includes tenant, source vendor, upstream id, and content hash. Dependencies: EXCITITOR-WEB-AOC-19-001. | Excititor WebService Guild, Observability Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) -EXCITITOR-WEB-AOC-19-003 `Guard + schema test harness` | TODO | Add unit/integration tests for schema validation, forbidden field rejection (`ERR_AOC_001/006/007`), and supersedes behavior using CycloneDX-VEX & CSAF fixtures with deterministic expectations. Dependencies: EXCITITOR-WEB-AOC-19-002. | QA Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) -EXCITITOR-WEB-AOC-19-004 `Batch ingest validation` | TODO | Build large fixture ingest covering mixed VEX statuses, verifying raw storage parity, metrics, and CLI `aoc verify` compatibility. Document load test/runbook updates. Dependencies: EXCITITOR-WEB-AOC-19-003. | Excititor WebService Guild, QA Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) +EXCITITOR-WEB-AOC-19-001 `Raw VEX ingestion APIs` | DONE (2025-11-08) | Implement `POST /ingest/vex`, `GET /vex/raw*`, and `POST /aoc/verify` endpoints. Enforce Authority scopes, tenant injection, and guard pipeline to ensure only immutable VEX facts are persisted. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) +EXCITITOR-WEB-AOC-19-002 `AOC observability + metrics` | DONE (2025-11-08) | Export metrics (`ingestion_write_total`, `aoc_violation_total`, signature verification counters) and tracing spans matching Conseiller naming. Ensure structured logging includes tenant, source vendor, upstream id, and content hash. Dependencies: EXCITITOR-WEB-AOC-19-001. | Excititor WebService Guild, Observability Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) +EXCITITOR-WEB-AOC-19-003 `Guard + schema test harness` | DONE (2025-11-08) | Add unit/integration tests for schema validation, forbidden field rejection (`ERR_AOC_001/006/007`), and supersedes behavior using CycloneDX-VEX & CSAF fixtures with deterministic expectations. Dependencies: EXCITITOR-WEB-AOC-19-002. | QA Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) +EXCITITOR-WEB-AOC-19-004 `Batch ingest validation` | DONE (2025-11-08) | Build large fixture ingest covering mixed VEX statuses, verifying raw storage parity, metrics, and CLI `aoc verify` compatibility. Document load test/runbook updates. Dependencies: EXCITITOR-WEB-AOC-19-003. | Excititor WebService Guild, QA Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) EXCITITOR-WEB-OAS-61-001 | TODO | Implement `/.well-known/openapi` discovery endpoint with spec version metadata. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) EXCITITOR-WEB-OAS-61-002 | TODO | Standardize error envelope responses and update controller/unit tests. Dependencies: EXCITITOR-WEB-OAS-61-001. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) EXCITITOR-WEB-OAS-62-001 | TODO | Add curated examples for VEX observation/linkset endpoints and ensure portal displays them. Dependencies: EXCITITOR-WEB-OAS-61-002. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) EXCITITOR-WEB-OAS-63-001 | TODO | Emit deprecation headers and update docs for retiring VEX APIs. Dependencies: EXCITITOR-WEB-OAS-62-001. | Excititor WebService Guild, API Governance Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) -EXCITITOR-WEB-OBS-50-001 `Telemetry adoption` | TODO | Adopt telemetry core for VEX APIs, ensure responses include trace IDs & correlation headers, and update structured logging for read endpoints. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) -EXCITITOR-WEB-OBS-51-001 `Observability health endpoints` | TODO | Implement `/obs/excititor/health` summarizing ingest/link SLOs, signature failure counts, and conflict trends for Console dashboards. Dependencies: EXCITITOR-WEB-OBS-50-001. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) +EXCITITOR-WEB-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Adopt telemetry core for VEX APIs, ensure responses include trace IDs & correlation headers, and update structured logging for read endpoints. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) +EXCITITOR-WEB-OBS-51-001 `Observability health endpoints` | DONE (2025-11-08) | Implement `/obs/excititor/health` summarizing ingest/link SLOs, signature failure counts, and conflict trends for Console dashboards. Dependencies: EXCITITOR-WEB-OBS-50-001. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) EXCITITOR-WEB-OBS-52-001 `Timeline streaming` | TODO | Provide SSE bridge for VEX timeline events with tenant filters, pagination, and guardrails. Dependencies: EXCITITOR-WEB-OBS-51-001. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) EXCITITOR-WEB-OBS-53-001 `Evidence APIs` | TODO | Expose `/evidence/vex/*` endpoints that fetch locker bundles, enforce scopes, and surface verification metadata. Dependencies: EXCITITOR-WEB-OBS-52-001. | Excititor WebService Guild, Evidence Locker Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) EXCITITOR-WEB-OBS-54-001 `Attestation APIs` | TODO | Add `/attestations/vex/*` endpoints returning DSSE verification state, builder identity, and chain-of-custody links. Dependencies: EXCITITOR-WEB-OBS-53-001. | Excititor WebService Guild (src/Excititor/StellaOps.Excititor.WebService/TASKS.md) diff --git a/docs/implplan/SPRINT_120_policy_reasoning.md b/docs/implplan/SPRINT_120_policy_reasoning.md index 1dbabf4fa..e2f1069bd 100644 --- a/docs/implplan/SPRINT_120_policy_reasoning.md +++ b/docs/implplan/SPRINT_120_policy_reasoning.md @@ -22,8 +22,8 @@ LEDGER-29-001 | DONE (2025-11-03) | Design ledger & projection schemas (tables/i LEDGER-29-002 | DONE (2025-11-03) | Implement ledger write API (`POST /vuln/ledger/events`) with validation, idempotency, hash chaining, and Merkle root computation job.
2025-11-03: Web service + domain scaffolding landed with canonical hashing helpers, in-memory repository, Merkle scheduler stub, request/response contracts, and unit tests covering hashing & conflict flows. Dependencies: LEDGER-29-001. | Findings Ledger Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-003 | DONE (2025-11-03) | Build projector worker that derives `findings_projection` rows from ledger events + policy determinations; ensure idempotent replay keyed by `(tenant,finding_id,policy_version)`.
2025-11-03: Postgres projection services landed with replay checkpoints, fixtures, and unit coverage (LEDGER-29-003). Dependencies: LEDGER-29-002. | Findings Ledger Guild, Scheduler Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-004 | DONE (2025-11-04) | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.
2025-11-04: Ledger service now calls `/api/policy/eval/batch` with resilient HttpClient, shared cache, and inline fallback; documentation/config samples updated; ledger tests executed (`dotnet test src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/StellaOps.Findings.Ledger.Tests.csproj --no-restore`). Dependencies: LEDGER-29-003. | Findings Ledger Guild, Policy Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) -LEDGER-29-005 | TODO | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. Dependencies: LEDGER-29-004. | Findings Ledger Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) -LEDGER-29-006 | TODO | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. Dependencies: LEDGER-29-005. | Findings Ledger Guild, Security Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) +LEDGER-29-005 | DONE | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. Dependencies: LEDGER-29-004. | Findings Ledger Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) +LEDGER-29-006 | DONE | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. Dependencies: LEDGER-29-005. | Findings Ledger Guild, Security Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-007 | TODO | Instrument metrics (`ledger_write_latency`, `projection_lag_seconds`, `ledger_events_total`), structured logs, and Merkle anchoring alerts; publish dashboards. Dependencies: LEDGER-29-006. | Findings Ledger Guild, Observability Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-008 | TODO | Develop unit/property/integration tests, replay/restore tooling, determinism harness, and load tests at 5M findings/tenant. Dependencies: LEDGER-29-007. | Findings Ledger Guild, QA Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) LEDGER-29-009 | TODO | Provide deployment manifests (Helm/Compose), backup/restore guidance, Merkle anchor externalization (optional), and offline kit instructions. Dependencies: LEDGER-29-008. | Findings Ledger Guild, DevOps Guild (src/Findings/StellaOps.Findings.Ledger/TASKS.md) @@ -106,8 +106,8 @@ POLICY-ENGINE-20-008 | TODO | Add unit/property/golden/perf suites covering poli POLICY-ENGINE-20-009 | TODO | Define Mongo schemas/indexes for `policies`, `policy_runs`, and `effective_finding_*`; implement migrations and tenant enforcement. Dependencies: POLICY-ENGINE-20-008. | Policy Guild, Storage Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) POLICY-ENGINE-27-001 | TODO | Extend compile outputs to include rule coverage metadata, symbol table, inline documentation, and rule index for editor autocomplete; persist deterministic hashes. Dependencies: POLICY-ENGINE-20-009. | Policy Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) POLICY-ENGINE-27-002 | TODO | Enhance simulate endpoints to emit rule firing counts, heatmap aggregates, sampled explain traces with deterministic ordering, and delta summaries for quick/batch sims. Dependencies: POLICY-ENGINE-27-001. | Policy Guild, Observability Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) -POLICY-ENGINE-27-003 | TODO | Implement complexity/time limit enforcement with compiler scoring, configurable thresholds, and structured diagnostics (`ERR_POL_COMPLEXITY`). Dependencies: POLICY-ENGINE-27-002. | Policy Guild, Security Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) -POLICY-ENGINE-27-004 | TODO | Update golden/property tests to cover new coverage metrics, symbol tables, explain traces, and complexity limits; provide fixtures for Registry/Console integration. Dependencies: POLICY-ENGINE-27-003. | Policy Guild, QA Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) +POLICY-ENGINE-27-003 | DONE | Implement complexity/time limit enforcement with compiler scoring, configurable thresholds, and structured diagnostics (`ERR_POL_COMPLEXITY`). Dependencies: POLICY-ENGINE-27-002. | Policy Guild, Security Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) +POLICY-ENGINE-27-004 | DONE | Update golden/property tests to cover new coverage metrics, symbol tables, explain traces, and complexity limits; provide fixtures for Registry/Console integration. Dependencies: POLICY-ENGINE-27-003. | Policy Guild, QA Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) POLICY-ENGINE-29-001 | TODO | Implement batch evaluation endpoint (`POST /policy/eval/batch`) returning determinations + rationale chain for sets of `(artifact,purl,version,advisory)` tuples; support pagination and cost budgets. Dependencies: POLICY-ENGINE-27-004. | Policy Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) POLICY-ENGINE-29-002 | TODO | Provide streaming simulation API comparing two policy versions, returning per-finding deltas without writes; align determinism with Vuln Explorer simulation. Dependencies: POLICY-ENGINE-29-001. | Policy Guild, Findings Ledger Guild (src/Policy/StellaOps.Policy.Engine/TASKS.md) diff --git a/docs/implplan/SPRINT_140_runtime_signals.md b/docs/implplan/SPRINT_140_runtime_signals.md index 599c840db..cfd93e9fa 100644 --- a/docs/implplan/SPRINT_140_runtime_signals.md +++ b/docs/implplan/SPRINT_140_runtime_signals.md @@ -48,7 +48,12 @@ Notes: - 2025-10-29: JSON parsers for Java/Node.js/Python/Go implemented; artifacts stored on filesystem with SHA-256 and callgraphs upserted into Mongo. Task ID | State | Task description | Owners (Source) --- | --- | --- | --- +SIGNALS-24-001 | DOING (2025-11-07) | Stand up Signals API skeleton with RBAC, sealed-mode config, DPoP/mTLS enforcement, and `/facts` scaffolding so downstream ingestion work can begin. Dependencies: AUTH-SIG-26-001. | Signals Guild, Authority Guild (src/Signals/StellaOps.Signals/TASKS.md) +SIGNALS-24-002 | DOING (2025-11-07) | Implement callgraph ingestion/normalization (Java/Node/Python/Go) with CAS persistence and retrieval APIs to feed reachability scoring. Dependencies: SIGNALS-24-001. | Signals Guild (src/Signals/StellaOps.Signals/TASKS.md) SIGNALS-24-003 | BLOCKED (2025-10-27) | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance.
2025-10-27: Depends on `SIGNALS-24-001` for base API host and authentication plumbing. | Signals Guild, Runtime Guild (src/Signals/StellaOps.Signals/TASKS.md) +> 2025-11-07: Waiting on SIGNALS-24-001 / SIGNALS-24-002 DOING work to land before flipping this to DOING. +> 2025-11-07: Upstream SIGNALS-24-001 / SIGNALS-24-002 now DOING; this flips to DOING once host + callgraph ingestion merge. +> 2025-11-08: Targeting 2025-11-09 merge for SIGNALS-24-001/002; schema + AOC contract drafted so SIGNALS-24-003 can move to DOING immediately after those PRs land (dependencies confirmed, none missing). SIGNALS-24-004 | BLOCKED (2025-10-27) | Deliver reachability scoring engine producing states/scores and writing to `reachability_facts`; expose configuration for weights. Dependencies: SIGNALS-24-003.
2025-10-27: Upstream ingestion pipelines (`SIGNALS-24-002/003`) blocked; scoring engine cannot proceed. | Signals Guild, Data Science (src/Signals/StellaOps.Signals/TASKS.md) SIGNALS-24-005 | BLOCKED (2025-10-27) | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. Dependencies: SIGNALS-24-004.
2025-10-27: Awaiting scoring engine and ingestion layers before wiring cache/events. | Signals Guild, Platform Events Guild (src/Signals/StellaOps.Signals/TASKS.md) diff --git a/docs/implplan/SPRINT_150_scheduling_automation.md b/docs/implplan/SPRINT_150_scheduling_automation.md index aa893b23e..a2255b026 100644 --- a/docs/implplan/SPRINT_150_scheduling_automation.md +++ b/docs/implplan/SPRINT_150_scheduling_automation.md @@ -51,7 +51,8 @@ Task ID | State | Task description | Owners (Source) --- | --- | --- | --- ORCH-SVC-38-101 | TODO | Standardize event envelope (policy/export/job lifecycle) with idempotency keys, ensure export/job failure events published to notifier bus with provenance metadata. Dependencies: ORCH-SVC-37-101. | Orchestrator Service Guild (src/Orchestrator/StellaOps.Orchestrator/TASKS.md) ORCH-SVC-41-101 | TODO | Register `pack-run` job type, persist run metadata, integrate logs/artifacts collection, and expose API for Task Runner scheduling. Dependencies: ORCH-SVC-38-101. | Orchestrator Service Guild (src/Orchestrator/StellaOps.Orchestrator/TASKS.md) -ORCH-SVC-42-101 | TODO | Stream pack run logs via SSE/WS, add manifest endpoints, enforce quotas, and emit pack run events to Notifications Studio. Dependencies: ORCH-SVC-41-101. | Orchestrator Service Guild (src/Orchestrator/StellaOps.Orchestrator/TASKS.md) +ORCH-SVC-42-101 | TODO | Stream pack run logs via SSE/WS, add manifest endpoints, enforce quotas, and emit pack run events to Notifications Studio. Dependencies: ORCH-SVC-41-101. | Orchestrator Service Guild (src/Orchestrator/StellaOps.Orchestrator/TASKS.md) +> 2025-11-07: Still NOT STARTED—Authority pack RBAC (AUTH-PACKS-43-001) remains BLOCKED pending these approvals/log-stream APIs. Not missing; needs staffing. ORCH-TEN-48-001 | TODO | Include `tenant_id`/`project_id` in job specs, set DB session context before processing, enforce context on all queries, and reject jobs missing tenant metadata. | Orchestrator Service Guild (src/Orchestrator/StellaOps.Orchestrator/TASKS.md) WORKER-GO-32-001 | TODO | Bootstrap Go SDK project with configuration binding, auth headers, job claim/acknowledge client, and smoke sample. | Worker SDK Guild (src/Orchestrator/StellaOps.Orchestrator.WorkerSdk.Go/TASKS.md) WORKER-GO-32-002 | TODO | Add heartbeat/progress helpers, structured logging hooks, Prometheus metrics, and jittered retry defaults. Dependencies: WORKER-GO-32-001. | Worker SDK Guild (src/Orchestrator/StellaOps.Orchestrator.WorkerSdk.Go/TASKS.md) @@ -90,7 +91,8 @@ SCHED-IMPACT-16-303 | TODO | Snapshot/compaction + invalidation for removed imag SCHED-SURFACE-01 | TODO | Evaluate Surface.FS pointers when planning delta scans to avoid redundant work and prioritise drift-triggered assets. | Scheduler Worker Guild (src/Scheduler/__Libraries/StellaOps.Scheduler.Worker/TASKS.md) SCHED-VULN-29-001 | TODO | Expose resolver job APIs (`POST /vuln/resolver/jobs`, `GET /vuln/resolver/jobs/{id}`) to trigger candidate recomputation per artifact/policy change with RBAC and rate limits. | Scheduler WebService Guild, Findings Ledger Guild (src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md) SCHED-VULN-29-002 | TODO | Provide projector lag metrics endpoint and webhook notifications for backlog breaches consumed by DevOps dashboards. Dependencies: SCHED-VULN-29-001. | Scheduler WebService Guild, Observability Guild (src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md) -SCHED-WEB-20-002 | BLOCKED (waiting on SCHED-WORKER-20-301) | Provide simulation trigger endpoint returning diff preview metadata and job state for UI/CLI consumption. | Scheduler WebService Guild (src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md) +SCHED-WEB-20-002 | BLOCKED (waiting on SCHED-WORKER-20-301) | Provide simulation trigger endpoint returning diff preview metadata and job state for UI/CLI consumption. | Scheduler WebService Guild (src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md) +> 2025-11-07: Worker counterpart (SCHED-WORKER-20-301) now DOING; revisit once API scaffolding lands. SCHED-WEB-21-004 | DONE (2025-11-04) | Persist graph job lifecycle to Mongo storage and publish `scheduler.graph.job.completed@1` events + outbound webhook to Cartographer. Dependencies: SCHED-WEB-20-002. | Scheduler WebService Guild, Scheduler Storage Guild (src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md) > 2025-11-04: Graph job completions now persist to Mongo with optimistic guards, emit Redis/webhook notifications once per transition, and refresh result URI metadata idempotently (tests cover service + Mongo store paths). SCHED-WORKER-21-203 | TODO | Export metrics (`graph_build_seconds`, `graph_jobs_inflight`, `overlay_lag_seconds`) and structured logs with tenant/graph identifiers. | Scheduler Worker Guild, Observability Guild (src/Scheduler/__Libraries/StellaOps.Scheduler.Worker/TASKS.md) diff --git a/docs/implplan/SPRINT_180_experience_sdks.md b/docs/implplan/SPRINT_180_experience_sdks.md index fac591d2d..e03b5d6ba 100644 --- a/docs/implplan/SPRINT_180_experience_sdks.md +++ b/docs/implplan/SPRINT_180_experience_sdks.md @@ -227,6 +227,14 @@ WEB-AOC-19-001 `Shared AOC guard primitives` | DONE (2025-11-07) | Provide `AOCF WEB-AOC-19-002 `Provenance & signature helpers` | TODO | Ship `ProvenanceBuilder`, checksum utilities, and signature verification helper integrated with guard logging. Cover DSSE/CMS formats with unit tests. Dependencies: WEB-AOC-19-001. | BE-Base Platform Guild (src/Web/StellaOps.Web/TASKS.md) WEB-AOC-19-003 `Analyzer + test fixtures` | TODO | Author Roslyn analyzer preventing ingestion modules from writing forbidden keys without guard, and provide shared test fixtures for guard validation used by Concelier/Excititor service tests. Dependencies: WEB-AOC-19-002. | QA Guild, BE-Base Platform Guild (src/Web/StellaOps.Web/TASKS.md) WEB-CONSOLE-23-001 `Global posture endpoints` | TODO | Provide consolidated `/console/dashboard` and `/console/filters` APIs returning tenant-scoped aggregates (findings by severity, VEX override counts, advisory deltas, run health, policy change log). Enforce AOC labelling, deterministic ordering, and cursor-based pagination for drill-down hints. | BE-Base Platform Guild, Product Analytics Guild (src/Web/StellaOps.Web/TASKS.md) +CONSOLE-VULN-29-001 `Vulnerability workspace` | DOING (2025-11-08) | Build `/console/vuln/*` APIs and filters surfacing tenant-scoped findings with policy/VEX badges so Docs/UI teams can document workflows. Dependencies: WEB-CONSOLE-23-001, CONCELIER-GRAPH-21-001. | Console Guild, BE-Base Platform Guild (src/Web/StellaOps.Web/TASKS.md) +> 2025-11-07: API scaffolding kicked off; `docs/advisory-ai/console.md` consuming placeholder responses until this lands. Scheduler/Signals hooks queued once filters stabilized. +> 2025-11-08: Driving filter + reachability badge wiring plus `/console/vuln/search` DTOs to keep DOCS-AIAI-31-004 on real payloads; aligning Signals/Scheduler dependencies now that upstream tickets exist. +> 2025-11-08: Published HTTP contract + sample payloads in `docs/api/console/workspaces.md` and `docs/api/console/samples/vuln-findings-sample.json` so Docs can stage screenshots while backend wires up. +CONSOLE-VEX-30-001 `VEX evidence workspace` | DOING (2025-11-08) | Provide `/console/vex/*` APIs streaming VEX statements, justification summaries, and advisory links with SSE refresh hooks. Dependencies: WEB-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001. | Console Guild, BE-Base Platform Guild (src/Web/StellaOps.Web/TASKS.md) +> 2025-11-07: Endpoint contract draft in progress to unblock DOCS-AIAI-31-004 screenshot capture once responses are wired. +> 2025-11-08: Building SSE controller + `/console/vex/events` payloads and syncing Scheduler Signals tasks so DOCS-AIAI-31-004 can embed live data. +> 2025-11-08: SSE schema + NDJSON sample captured in `docs/api/console/workspaces.md` and `docs/api/console/samples/vex-statement-sse.ndjson`; waiting on Scheduler topic hook-up. WEB-CONSOLE-23-002 `Live status & SSE proxy` | TODO | Expose `/console/status` polling endpoint and `/console/runs/{id}/stream` SSE/WebSocket proxy with heartbeat/backoff, queue lag metrics, and auth scope enforcement. Surface request IDs + retry headers. Dependencies: WEB-CONSOLE-23-001. | BE-Base Platform Guild, Scheduler Guild (src/Web/StellaOps.Web/TASKS.md) WEB-CONSOLE-23-003 `Evidence export orchestrator` | TODO | Add `/console/exports` POST/GET routes coordinating evidence bundle creation, streaming CSV/JSON exports, checksum manifest retrieval, and signed attestation references. Ensure requests honor tenant + policy scopes and expose job tracking metadata. Dependencies: WEB-CONSOLE-23-002. | BE-Base Platform Guild, Policy Guild (src/Web/StellaOps.Web/TASKS.md) WEB-CONSOLE-23-004 `Global search router` | TODO | Implement `/console/search` endpoint accepting CVE/GHSA/PURL/SBOM identifiers, performing fan-out queries with caching, ranking, and deterministic tie-breaking. Return typed results for Console navigation; respect result caps and latency SLOs. Dependencies: WEB-CONSOLE-23-003. | BE-Base Platform Guild (src/Web/StellaOps.Web/TASKS.md) diff --git a/docs/implplan/SPRINT_190_ops_offline.md b/docs/implplan/SPRINT_190_ops_offline.md index 057687d23..03c6a1150 100644 --- a/docs/implplan/SPRINT_190_ops_offline.md +++ b/docs/implplan/SPRINT_190_ops_offline.md @@ -48,7 +48,9 @@ DEVOPS-AIRGAP-56-001 | TODO | Ship deny-all egress policies for Kubernetes (Netw DEVOPS-AIRGAP-56-002 | TODO | Provide import tooling for bundle staging: checksum validation, offline object-store loader scripts, removable media guidance. Dependencies: DEVOPS-AIRGAP-56-001. | DevOps Guild, AirGap Importer Guild (ops/devops/TASKS.md) DEVOPS-AIRGAP-56-003 | TODO | Build Bootstrap Pack pipeline bundling images/charts, generating checksums, and publishing manifest for offline transfer. Dependencies: DEVOPS-AIRGAP-56-002. | DevOps Guild, Container Distribution Guild (ops/devops/TASKS.md) DEVOPS-AIRGAP-57-001 | TODO | Automate Mirror Bundle creation jobs with dual-control approvals, artifact signing, and checksum publication. Dependencies: DEVOPS-AIRGAP-56-003. | DevOps Guild, Mirror Creator Guild (ops/devops/TASKS.md) -DEVOPS-AIRGAP-57-002 | TODO | Configure sealed-mode CI tests that run services with sealed flag and ensure no egress occurs (iptables + mock DNS). Dependencies: DEVOPS-AIRGAP-57-001. | DevOps Guild, Authority Guild (ops/devops/TASKS.md) +DEVOPS-AIRGAP-57-002 | DOING (2025-11-08) | Configure sealed-mode CI tests that run services with sealed flag and ensure no egress occurs (iptables + mock DNS). Dependencies: DEVOPS-AIRGAP-57-001. | DevOps Guild, Authority Guild (ops/devops/TASKS.md) +> 2025-11-07: Harness scaffolded at `ops/devops/sealed-mode-ci/*` (README + runner script); integrate into CI to unblock AUTH-AIRGAP-57-001. +> 2025-11-08: `sealed-mode-compose.yml`, `run-sealed-ci.sh`, and `egress_probe.py` committed plus a `sealed-mode-ci` workflow stage that uploads `artifacts/sealed-mode-ci//authority-sealed-ci.json`; Authority can now read the sealed evidence feed. DEVOPS-AIRGAP-58-001 | TODO | Provide local SMTP/syslog container templates and health checks for sealed environments; integrate into Bootstrap Pack. Dependencies: DEVOPS-AIRGAP-57-002. | DevOps Guild, Notifications Guild (ops/devops/TASKS.md) DEVOPS-AIRGAP-58-002 | TODO | Ship sealed-mode observability stack (Prometheus/Grafana/Tempo/Loki) pre-configured with offline dashboards and no remote exporters. Dependencies: DEVOPS-AIRGAP-58-001. | DevOps Guild, Observability Guild (ops/devops/TASKS.md) DEVOPS-AOC-19-001 | BLOCKED (2025-10-26) | Integrate the AOC Roslyn analyzer and guard tests into CI, failing builds when ingestion projects attempt banned writes. | DevOps Guild, Platform Guild (ops/devops/TASKS.md) @@ -235,4 +237,24 @@ PROV-OBS-54-001 | TODO | Deliver verification library that validates DSSE signat PROV-OBS-54-002 | TODO | Generate .NET global tool for local verification + embed command helpers for CLI `stella forensic verify`. Provide deterministic packaging and offline kit instructions. Dependencies: PROV-OBS-54-001. | Provenance Guild, DevEx/CLI Guild (src/Provenance/StellaOps.Provenance.Attestation/TASKS.md) +[Ops & Offline] 190.K) Sovereign Crypto Enablement + +Depends on: Sprint 100.A - Attestor, Sprint 110.A - AdvisoryAI, Sprint 120.A - AirGap, Sprint 130.A - Scanner, Sprint 140.A - Graph, Sprint 150.A - Orchestrator, Sprint 160.A - EvidenceLocker, Sprint 170.A - Notifier, Sprint 180.A - Cli + +Summary: Deliver RootPack_RU-ready sovereign crypto providers (CryptoPro + PKCS#11), configuration knobs, deterministic tests, and repo-wide crypto routing audit. + +Task ID | State | Task description | Owners (Source) +--- | --- | --- | --- +SEC-CRYPTO-90-001 | DONE (2025-11-07) | Produce RootPack_RU sovereign crypto implementation plan, identify provider strategy (CryptoPro + PKCS#11), and slot work into Sprint 190 with task breakdown. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +SEC-CRYPTO-90-002 | DONE (2025-11-07) | Extend signature/catalog constants and configuration schema to recognize `GOST12-256/512`, regional crypto profiles, and provider preference ordering. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +SEC-CRYPTO-90-003 | DONE (2025-11-07) | Implement `StellaOps.Cryptography.Plugin.CryptoPro` provider (sign/verify/JWK export) using CryptoPro CSP/GostCryptography with deterministic logging + tests. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +SEC-CRYPTO-90-004 | DONE (2025-11-07) | Implement `StellaOps.Cryptography.Plugin.Pkcs11Gost` provider (Rutoken/JaCarta) via Pkcs11Interop, configurable slot/pin/module management, and disposal safeguards. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +SEC-CRYPTO-90-005 | DONE (2025-11-08) | Add configuration-driven provider selection (`crypto.regionalProfiles`), CLI/diagnostic verb to list providers/keys, and deterministic telemetry for usage. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +SEC-CRYPTO-90-006 | DONE (2025-11-08) | Build deterministic test harness (Streebog + signature vectors), manual runbooks for hardware validation, and capture RootPack audit metadata. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +SEC-CRYPTO-90-007 | DONE (2025-11-08) | Package RootPack_RU artifacts (plugin binaries, config templates, trust anchors) and document deployment/install steps + compliance evidence. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +SEC-CRYPTO-90-008 | DONE (2025-11-08) | Audit repository for any cryptography usage bypassing `StellaOps.Cryptography` and file remediation tasks to route through providers. | Security Guild (src/__Libraries/StellaOps.Cryptography/TASKS.md) +AUTH-CRYPTO-90-001 | DOING (2025-11-08) | Migrate Authority signing/key-loading paths (provider registry + crypto hash) so regional bundles can select sovereign providers per docs/security/crypto-routing-audit-2025-11-07.md. | Authority Core & Security Guild (src/Authority/StellaOps.Authority/TASKS.md) +SCANNER-CRYPTO-90-001 | DONE (2025-11-08) | Route remaining Scanner Worker hashing/digest consumers (Surface pointers, manifest publishers, CAS helpers, Sbomer plugins) through ICryptoHash/provider registry.
2025-11-08: EntryTrace execution, Surface manifest writer, Local CAS client, and Sbomer descriptor generator now accept ICryptoHash; tests updated with CryptoHashFactory/TestCryptoHash helpers. | Scanner Worker Guild & Security Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md) +CONCELIER-CRYPTO-90-001 | DOING (2025-11-08) | Route OpenAPI discovery hashing plus Concelier mirror/RU connectors through `ICryptoHash`/provider registry so sovereign bundles can swap CryptoPro/PKCS#11 keys without code changes. | Concelier WebService Guild & Security Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) + If all tasks are done - read next sprint section - SPRINT_200_documentation_process.md diff --git a/docs/implplan/SPRINT_200_documentation_process.md b/docs/implplan/SPRINT_200_documentation_process.md index c81dd478a..22a79ee9b 100644 --- a/docs/implplan/SPRINT_200_documentation_process.md +++ b/docs/implplan/SPRINT_200_documentation_process.md @@ -5,15 +5,15 @@ Depends on: Sprint 100.A - Attestor, Sprint 110.A - AdvisoryAI, Sprint 120.A - A Summary: Documentation & Process focus on Docs Tasks (phase Md.I). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- -DOCS-AIAI-31-001 | TODO | Publish `/docs/advisory-ai/overview.md` covering capabilities, guardrails, RBAC. | Docs Guild, Advisory AI Guild (docs/TASKS.md) -DOCS-AIAI-31-002 | TODO | Author `/docs/advisory-ai/architecture.md` detailing RAG pipeline, deterministics, caching, model options. Dependencies: DOCS-AIAI-31-001. | Docs Guild, Advisory AI Guild (docs/TASKS.md) -DOCS-AIAI-31-003 | TODO | Write `/docs/advisory-ai/api.md` describing endpoints, schemas, errors, rate limits. Dependencies: DOCS-AIAI-31-002. | Docs Guild, Advisory AI Guild (docs/TASKS.md) -DOCS-AIAI-31-004 | TODO | Create `/docs/advisory-ai/console.md` with screenshots, a11y notes, copy-as-ticket instructions. Dependencies: DOCS-AIAI-31-003. | Docs Guild, Console Guild (docs/TASKS.md) -DOCS-AIAI-31-005 | TODO | Publish `/docs/advisory-ai/cli.md` covering commands, exit codes, scripting patterns. Dependencies: DOCS-AIAI-31-004. | Docs Guild, DevEx/CLI Guild (docs/TASKS.md) -DOCS-AIAI-31-006 | TODO | Update `/docs/policy/assistant-parameters.md` covering temperature, token limits, ranking weights, TTLs. Dependencies: DOCS-AIAI-31-005. | Docs Guild, Policy Guild (docs/TASKS.md) -DOCS-AIAI-31-007 | TODO | Write `/docs/security/assistant-guardrails.md` detailing redaction, injection defense, logging. Dependencies: DOCS-AIAI-31-006. | Docs Guild, Security Guild (docs/TASKS.md) -DOCS-AIAI-31-008 | TODO | Publish `/docs/sbom/remediation-heuristics.md` (feasibility scoring, blast radius). Dependencies: DOCS-AIAI-31-007. | Docs Guild, SBOM Service Guild (docs/TASKS.md) -DOCS-AIAI-31-009 | TODO | Create `/docs/runbooks/assistant-ops.md` for warmup, cache priming, model outages, scaling. Dependencies: DOCS-AIAI-31-008. | Docs Guild, DevOps Guild (docs/TASKS.md) +DOCS-AIAI-31-001 | DONE (2025-11-03) | Publish `/docs/advisory-ai/overview.md` covering capabilities, guardrails, RBAC. | Docs Guild, Advisory AI Guild (docs/TASKS.md) +DOCS-AIAI-31-002 | DONE (2025-11-03) | Author `/docs/advisory-ai/architecture.md` detailing RAG pipeline, deterministics, caching, model options. Dependencies: DOCS-AIAI-31-001. | Docs Guild, Advisory AI Guild (docs/TASKS.md) +DOCS-AIAI-31-003 | DONE (2025-11-03) | Write `/docs/advisory-ai/api.md` describing endpoints, schemas, errors, rate limits. Dependencies: DOCS-AIAI-31-002. | Docs Guild, Advisory AI Guild (docs/TASKS.md) +DOCS-AIAI-31-004 | DOING (2025-11-07) | Create `/docs/advisory-ai/console.md` with screenshots, a11y notes, copy-as-ticket instructions. Dependencies: DOCS-AIAI-31-003, CONSOLE-VULN-29-001, CONSOLE-VEX-30-001, EXCITITOR-CONSOLE-23-001. | Docs Guild, Console Guild (docs/TASKS.md) +DOCS-AIAI-31-005 | BLOCKED (2025-11-03) | Publish `/docs/advisory-ai/cli.md` covering commands, exit codes, scripting patterns. Dependencies: DOCS-AIAI-31-004, CLI-VULN-29-001, CLI-VEX-30-001. | Docs Guild, DevEx/CLI Guild (docs/TASKS.md) +DOCS-AIAI-31-006 | BLOCKED (2025-11-03) | Update `/docs/policy/assistant-parameters.md` covering temperature, token limits, ranking weights, TTLs. Dependencies: DOCS-AIAI-31-005, POLICY-ENGINE-31-001. | Docs Guild, Policy Guild (docs/TASKS.md) +DOCS-AIAI-31-007 | DONE (2025-11-07) | Write `/docs/security/assistant-guardrails.md` detailing redaction, injection defense, logging. Dependencies: DOCS-AIAI-31-006. | Docs Guild, Security Guild (docs/TASKS.md) +DOCS-AIAI-31-008 | BLOCKED (2025-11-03) | Publish `/docs/sbom/remediation-heuristics.md` (feasibility scoring, blast radius). Dependencies: DOCS-AIAI-31-007, SBOM-AIAI-31-001. | Docs Guild, SBOM Service Guild (docs/TASKS.md) +DOCS-AIAI-31-009 | BLOCKED (2025-11-03) | Create `/docs/runbooks/assistant-ops.md` for warmup, cache priming, model outages, scaling. Dependencies: DOCS-AIAI-31-008, DEVOPS-AIAI-31-001. | Docs Guild, DevOps Guild (docs/TASKS.md) DOCS-AIRGAP-56-001 | TODO | Publish `/docs/airgap/overview.md` outlining modes, lifecycle, responsibilities, and imposed rule banner. | Docs Guild, AirGap Controller Guild (docs/TASKS.md) DOCS-AIRGAP-56-002 | TODO | Author `/docs/airgap/sealing-and-egress.md` covering network policies, EgressPolicy facade usage, and verification steps. Dependencies: DOCS-AIRGAP-56-001. | Docs Guild, DevOps Guild (docs/TASKS.md) DOCS-AIRGAP-56-003 | TODO | Create `/docs/airgap/mirror-bundles.md` describing bundle format, DSSE/TUF/Merkle validation, creation/import workflows. Dependencies: DOCS-AIRGAP-56-002. | Docs Guild, Exporter Guild (docs/TASKS.md) diff --git a/docs/implplan/SPRINT_201_reachability_explainability.md b/docs/implplan/SPRINT_201_reachability_explainability.md new file mode 100644 index 000000000..c6948234f --- /dev/null +++ b/docs/implplan/SPRINT_201_reachability_explainability.md @@ -0,0 +1,17 @@ +# Sprint 201 - Reachability Explainability & Replay Evidence + +[Reachability Delivery] 201.A) Runtime facts + static callgraph union +Depends on: Sprint 140 Runtime Signals, Sprint 185 Replay Core, Sprint 186 Scanner Record Mode, Sprint 187 Evidence & CLI Replay +Summary: Close the explainability gaps by wiring Zastava runtime sampling, Scanner language lifters, Signals scoring, Replay manifests, docs, and test harnesses around the reachbench fixture packs. + +Task ID | State | Task description | Owners (Source) +--- | --- | --- | --- +ZASTAVA-REACH-201-001 | TODO | Implement runtime symbol sampling in `StellaOps.Zastava.Observer` (EntryTrace-aware shell AST + build-id capture) and stream ND-JSON batches to Signals `/runtime-facts`, including CAS pointers for traces. Update runbook + config references. | Zastava Observer Guild (`src/Zastava/StellaOps.Zastava.Observer/TASKS.md`) +SCAN-REACH-201-002 | DOING (2025-11-08) | Ship language-aware static lifters (JVM, .NET/Roslyn+IL, Go SSA, Node/Deno TS AST, Rust MIR, Swift SIL, shell/binary analyzers) in Scanner Worker; emit canonical SymbolIDs, CAS-stored graphs, and attach reachability tags to SBOM components. | Scanner Worker Guild (`src/Scanner/StellaOps.Scanner.Worker/TASKS.md`) +SIGNALS-REACH-201-003 | DOING (2025-11-08) | Extend Signals ingestion to accept the new multi-language graphs + runtime facts, normalize into `reachability_graphs` CAS layout, and expose retrieval APIs for Policy/CLI. | Signals Guild (`src/Signals/StellaOps.Signals/TASKS.md`) +SIGNALS-REACH-201-004 | DOING (2025-11-08) | Build the reachability scoring engine (state/score/confidence), wire Redis caches + `signals.fact.updated` events, and integrate reachability weights defined in `docs/11_DATA_SCHEMAS.md`. | Signals Guild · Policy Guild (`src/Signals/StellaOps.Signals/TASKS.md`, `src/Policy/StellaOps.Policy.Engine/TASKS.md`) +REPLAY-REACH-201-005 | DOING (2025-11-08) | Update `StellaOps.Replay.Core` manifest schema + bundle writer so replay packs capture reachability graphs, runtime traces, analyzer versions, and evidence hashes; document new CAS namespace. | BE-Base Platform Guild (`src/__Libraries/StellaOps.Replay.Core/TASKS.md`) +DOCS-REACH-201-006 | TODO | Author the reachability doc set (`docs/signals/reachability.md`, `callgraph-formats.md`, `runtime-facts.md`, CLI/UI appendices) plus update Zastava + Replay guides with the new evidence and operators’ workflow. | Docs Guild (`docs/TASKS.md`) +QA-REACH-201-007 | TODO | Integrate `reachbench-2025-expanded` fixture pack under `tests/reachability/`, add evaluator harness tests that validate reachable vs unreachable cases, and wire CI guidance for deterministic runs. | QA Guild (`tests/README.md`) + +> 2025-11-07: reachbench starter + expanded packs staged under repo root; consuming guilds must relocate fixtures into `tests/reachability/fixtures/` as part of QA-REACH-201-007 before enabling CI. diff --git a/docs/modules/devops/architecture.md b/docs/modules/devops/architecture.md index e277d8837..a30d43da1 100644 --- a/docs/modules/devops/architecture.md +++ b/docs/modules/devops/architecture.md @@ -75,9 +75,10 @@ At startup, services **self‑advertise** their semver & channel; the UI surface ### 2.4 Gates & tests * **Static**: linters, codegen checks, protobuf API freeze (backward‑compat tests). -* **Unit/integration**: per‑component, plus **end‑to‑end** flows (scan→vex→policy→sign→attest). -* **Perf SLOs**: hot paths (SBOM compose, diff, export) measured against budgets. -* **Security**: dependency audit vs Concelier export; container hardening tests; minimal caps. +* **Unit/integration**: per-component, plus **end-to-end** flows (scan→vex→policy→sign→attest). +* **Perf SLOs**: hot paths (SBOM compose, diff, export) measured against budgets. +* **Security**: dependency audit vs Concelier export; container hardening tests; minimal caps. +* **Deployment assets**: `Build Test Deploy` workflow’s `profile-validation` job installs Helm and runs `helm lint` + `helm template` against `deploy/helm/stellaops` for every `values*.yaml`, catching ConfigMap/templating drift before merges. * **Analyzer smoke**: restart-time language plug-ins (currently Python) verified via `dotnet run --project src/Tools/LanguageAnalyzerSmoke` to ensure manifest integrity plus cold vs warm determinism (< 30 s / < 5 s budgets); the harness logs deviations from repository goldens for follow-up. * **Canary cohort**: internal staging + selected customers; one week on **edge** before **stable** tag. diff --git a/docs/modules/excititor/architecture.md b/docs/modules/excititor/architecture.md index ab351f743..b1b91b6e0 100644 --- a/docs/modules/excititor/architecture.md +++ b/docs/modules/excititor/architecture.md @@ -1,751 +1,764 @@ -# component_architecture_excititor.md — **Stella Ops Excititor** (Sprint 22) - -> Consolidates the VEX ingestion guardrails from Epic 1 with consensus and AI-facing requirements from Epics 7 and 8. This is the authoritative architecture record for Excititor. - -> **Scope.** This document specifies the **Excititor** service: its purpose, trust model, data structures, observation/linkset pipelines, APIs, plug-in contracts, storage schema, performance budgets, testing matrix, and how it integrates with Concelier, Policy Engine, and evidence surfaces. It is implementation-ready. - ---- - -## 0) Mission & role in the platform - -**Mission.** Convert heterogeneous **VEX** statements (OpenVEX, CSAF VEX, CycloneDX VEX; vendor/distro/platform sources) into immutable **VEX observations**, correlate them into **linksets** that retain provenance/conflicts without precedence, and publish deterministic evidence exports and events that Policy Engine, Console, and CLI use to suppress or explain findings. - -**Boundaries.** - -* Excititor **does not** decide PASS/FAIL. It supplies **evidence** (statuses + justifications + provenance weights). -* Excititor preserves **conflicting observations** unchanged; consensus (when enabled) merely annotates how policy might choose, but raw evidence remains exportable. -* VEX consumption is **backend-only**: Scanner never applies VEX. The backend’s **Policy Engine** asks Excititor for status evidence and then decides what to show. - ---- - -## 1) Aggregation guardrails (AOC baseline) - -Excititor enforces the same ingestion covenant as Concelier, tailored to VEX payloads: - -1. **Immutable `vex_raw` documents.** Upstream OpenVEX/CSAF/CycloneDX files are stored verbatim (`content.raw`) with provenance (`issuer`, `statement_id`, timestamps, signatures). Revisions append new versions linked by `supersedes`. -2. **No derived consensus at ingest time.** Fields such as `effective_status`, `merged_state`, `severity`, or reachability are forbidden. Roslyn analyzers and runtime guards block violations before writes. -3. **Linkset-only joins.** Product aliases, CVE keys, SBOM hints, and references live under `linkset`; ingestion must never mutate the underlying statement. -4. **Deterministic canonicalisation.** Writers sort JSON keys/arrays, normalize timestamps (UTC ISO‑8601), and hash content for reproducible exports. -5. **AOC verifier.** `StellaOps.AOC.Verifier` runs in CI and production, checking schema compliance, provenance completeness, sorted collections, and signature metadata. - -### 1.1 VEX raw document shape - -```json -{ - "_id": "vex_raw:openvex:VEX-2025-00001:v2", - "source": { - "issuer": "vendor:redhat", - "stream": "openvex", - "api": "https://vendor/api/vex/VEX-2025-00001.json", - "collector_version": "excititor/0.9.4" - }, - "upstream": { - "statement_id": "VEX-2025-00001", - "document_version": "2025-08-30T12:00:00Z", - "fetched_at": "2025-08-30T12:05:00Z", - "received_at": "2025-08-30T12:05:01Z", - "content_hash": "sha256:...", - "signature": { - "present": true, - "format": "dsse", - "key_id": "rekor:uuid", - "sig": "base64..." - } - }, - "content": { - "format": "openvex", - "spec_version": "1.0", - "raw": { /* upstream statement */ } - }, - "identifiers": { - "cve": ["CVE-2025-13579"], - "products": [ - {"purl": "pkg:rpm/redhat/openssl@3.0.9", "component": "openssl"} - ] - }, - "linkset": { - "aliases": ["REDHAT:RHSA-2025:1234"], - "sbom_products": ["pkg:rpm/redhat/openssl@3.0.9"], - "justifications": ["reasonable_worst_case_assumption"], - "references": [ - {"type": "advisory", "url": "https://..."} - ] - }, - "supersedes": "vex_raw:openvex:VEX-2025-00001:v1", - "tenant": "default" -} -``` - -### 1.2 Issuer trust registry - -To enable Epic 7’s consensus lens, Excititor maintains `vex_issuer_registry` documents containing: - -- `issuer_id`, canonical name, and allowed domains. -- `trust.tier` (`critical`, `high`, `medium`, `low`), `trust.confidence` (0–1). -- `products` PURL patterns the issuer is authoritative for. -- `signing_keys` with key IDs and expiry. -- `last_validated_at`, `revocation_status`. - -The registry is distributed as a signed bundle and cached locally; ingestion rejects statements from issuers without registry entries or valid signatures. - -### 1.3 Normalised tuple store - -Excititor derives `vex_normalized` tuples (without making decisions) for downstream consumers: - -```json -{ - "advisory_key": "CVE-2025-13579", - "artifact": "pkg:rpm/redhat/openssl@3.0.9", - "issuer": "vendor:redhat", - "status": "not_affected", - "justification": "component_not_present", - "scope": "runtime_path", - "timestamp": "2025-08-30T12:00:00Z", - "trust": {"tier": "high", "confidence": 0.95}, - "statement_id": "VEX-2025-00001:v2", - "content_hash": "sha256:..." -} -``` - +# component_architecture_excititor.md — **Stella Ops Excititor** (Sprint 22) + +> Consolidates the VEX ingestion guardrails from Epic 1 with consensus and AI-facing requirements from Epics 7 and 8. This is the authoritative architecture record for Excititor. + +> **Scope.** This document specifies the **Excititor** service: its purpose, trust model, data structures, observation/linkset pipelines, APIs, plug-in contracts, storage schema, performance budgets, testing matrix, and how it integrates with Concelier, Policy Engine, and evidence surfaces. It is implementation-ready. + +--- + +## 0) Mission & role in the platform + +**Mission.** Convert heterogeneous **VEX** statements (OpenVEX, CSAF VEX, CycloneDX VEX; vendor/distro/platform sources) into immutable **VEX observations**, correlate them into **linksets** that retain provenance/conflicts without precedence, and publish deterministic evidence exports and events that Policy Engine, Console, and CLI use to suppress or explain findings. + +**Boundaries.** + +* Excititor **does not** decide PASS/FAIL. It supplies **evidence** (statuses + justifications + provenance weights). +* Excititor preserves **conflicting observations** unchanged; consensus (when enabled) merely annotates how policy might choose, but raw evidence remains exportable. +* VEX consumption is **backend-only**: Scanner never applies VEX. The backend’s **Policy Engine** asks Excititor for status evidence and then decides what to show. + +--- + +## 1) Aggregation guardrails (AOC baseline) + +Excititor enforces the same ingestion covenant as Concelier, tailored to VEX payloads: + +1. **Immutable `vex_raw` documents.** Upstream OpenVEX/CSAF/CycloneDX files are stored verbatim (`content.raw`) with provenance (`issuer`, `statement_id`, timestamps, signatures). Revisions append new versions linked by `supersedes`. +2. **No derived consensus at ingest time.** Fields such as `effective_status`, `merged_state`, `severity`, or reachability are forbidden. Roslyn analyzers and runtime guards block violations before writes. +3. **Linkset-only joins.** Product aliases, CVE keys, SBOM hints, and references live under `linkset`; ingestion must never mutate the underlying statement. + +**Raw VEX endpoints (WebService)** + +- `POST /ingest/vex` (`scope: vex.admin`) accepts deterministic `VexIngestRequest` payloads. Clients must send `X-Stella-Tenant`. Optional dependencies (e.g., orchestrators, loggers) are wired through `[FromServices] SomeType? service = null` parameters so tests do not need bespoke service registrations. +- `GET /vex/raw`, `GET /vex/raw/{digest}`, and `GET /vex/raw/{digest}/provenance` (`scope: vex.read`) expose raw documents, cursored listings, and metadata-only projections. +- `POST /aoc/verify` replays stored documents through the Aggregation-Only Contract for audits and Grafana alert sources. +- To satisfy the AOC rule forbidding derived data, serialized raw responses omit the `statements` array unless replay tooling explicitly materializes it. +- Optional/minor DI dependencies must be declared as `[FromServices] IFoo? foo = null` parameters so host startup (and tests) remain stable when the service is not registered. + +4. **Deterministic canonicalisation.** Writers sort JSON keys/arrays, normalize timestamps (UTC ISO‑8601), and hash content for reproducible exports. +5. **AOC verifier.** `StellaOps.AOC.Verifier` runs in CI and production, checking schema compliance, provenance completeness, sorted collections, and signature metadata. + +### 1.1 VEX raw document shape + +```json +{ + "_id": "vex_raw:openvex:VEX-2025-00001:v2", + "source": { + "issuer": "vendor:redhat", + "stream": "openvex", + "api": "https://vendor/api/vex/VEX-2025-00001.json", + "collector_version": "excititor/0.9.4" + }, + "upstream": { + "statement_id": "VEX-2025-00001", + "document_version": "2025-08-30T12:00:00Z", + "fetched_at": "2025-08-30T12:05:00Z", + "received_at": "2025-08-30T12:05:01Z", + "content_hash": "sha256:...", + "signature": { + "present": true, + "format": "dsse", + "key_id": "rekor:uuid", + "sig": "base64..." + } + }, + "content": { + "format": "openvex", + "spec_version": "1.0", + "raw": { /* upstream statement */ } + }, + "identifiers": { + "cve": ["CVE-2025-13579"], + "products": [ + {"purl": "pkg:rpm/redhat/openssl@3.0.9", "component": "openssl"} + ] + }, + "linkset": { + "aliases": ["REDHAT:RHSA-2025:1234"], + "sbom_products": ["pkg:rpm/redhat/openssl@3.0.9"], + "justifications": ["reasonable_worst_case_assumption"], + "references": [ + {"type": "advisory", "url": "https://..."} + ] + }, + "supersedes": "vex_raw:openvex:VEX-2025-00001:v1", + "tenant": "default" +} +``` + +### 1.2 Issuer trust registry + +To enable Epic 7’s consensus lens, Excititor maintains `vex_issuer_registry` documents containing: + +- `issuer_id`, canonical name, and allowed domains. +- `trust.tier` (`critical`, `high`, `medium`, `low`), `trust.confidence` (0–1). +- `products` PURL patterns the issuer is authoritative for. +- `signing_keys` with key IDs and expiry. +- `last_validated_at`, `revocation_status`. + +The registry is distributed as a signed bundle and cached locally; ingestion rejects statements from issuers without registry entries or valid signatures. + +### 1.3 Normalised tuple store + +Excititor derives `vex_normalized` tuples (without making decisions) for downstream consumers: + +```json +{ + "advisory_key": "CVE-2025-13579", + "artifact": "pkg:rpm/redhat/openssl@3.0.9", + "issuer": "vendor:redhat", + "status": "not_affected", + "justification": "component_not_present", + "scope": "runtime_path", + "timestamp": "2025-08-30T12:00:00Z", + "trust": {"tier": "high", "confidence": 0.95}, + "statement_id": "VEX-2025-00001:v2", + "content_hash": "sha256:..." +} +``` + These tuples allow VEX Lens to compute deterministic consensus without re-parsing heavy upstream documents. Excititor workers now hydrate signature metadata with issuer trust data retrieved from the Issuer Directory service. The worker-side IssuerDirectoryClient performs tenant-aware lookups (including global fallbacks) and caches responses offline so attestation verification exposes an effective trust weight alongside the cryptographic details captured on ingest. - -### 1.4 AI-ready citations - -`GET /v1/vex/statements/{advisory_key}` produces sorted JSON responses containing raw statement metadata (`issuer`, `content_hash`, `signature`), normalised tuples, and provenance pointers. Advisory AI consumes this endpoint to build retrieval contexts with explicit citations. - ---- - -## 2) Inputs, outputs & canonical domain - -### 1.1 Accepted input formats (ingest) - -* **OpenVEX** JSON documents (attested or raw). -* **CSAF VEX** 2.x (vendor PSIRTs and distros commonly publish CSAF). -* **CycloneDX VEX** 1.4+ (standalone VEX or embedded VEX blocks). -* **OCI‑attached attestations** (VEX statements shipped as OCI referrers) — optional connectors. - -All connectors register **source metadata**: provider identity, trust tier, signature expectations (PGP/cosign/PKI), fetch windows, rate limits, and time anchors. - -### 1.2 Canonical model (observations & linksets) - -#### VexObservation - -```jsonc -observationId // {tenant}:{providerId}:{upstreamId}:{revision} -tenant -providerId // e.g., redhat, suse, ubuntu, osv -streamId // connector stream (csaf, openvex, cyclonedx, attestation) -upstream{ - upstreamId, - documentVersion?, - fetchedAt, - receivedAt, - contentHash, - signature{present, format?, keyId?, signature?} -} -statements[ - { - vulnerabilityId, - productKey, - status, // affected | not_affected | fixed | under_investigation - justification?, - introducedVersion?, - fixedVersion?, - lastObserved, - locator?, // JSON Pointer/line for provenance - evidence?[] - } -] -content{ - format, - specVersion?, - raw -} -linkset{ - aliases[], // CVE/GHSA/vendor IDs - purls[], - cpes[], - references[{type,url}], - reconciledFrom[] -} -supersedes? -createdAt -attributes? -``` - -#### VexLinkset - -```jsonc -linksetId // sha256 over sorted (tenant, vulnId, productKey, observationIds) -tenant -key{ - vulnerabilityId, - productKey, - confidence // low|medium|high -} -observations[] = [ - { - observationId, - providerId, - status, - justification?, - introducedVersion?, - fixedVersion?, - evidence?, - collectedAt - } -] -aliases{ - primary, - others[] -} -purls[] -cpes[] -conflicts[]? // see VexLinksetConflict -createdAt -updatedAt -``` - -#### VexLinksetConflict - -```jsonc -conflictId -type // status-mismatch | justification-divergence | version-range-clash | non-joinable-overlap | metadata-gap -field? // optional pointer for UI rendering -statements[] // per-observation values with providerId + status/justification/version data -confidence -detectedAt -``` - -#### VexConsensus (optional) - -```jsonc -consensusId // sha256(vulnerabilityId, productKey, policyRevisionId) -vulnerabilityId -productKey -rollupStatus // derived by Excititor policy adapter (linkset aware) -sources[] // observation references with weight, accepted flag, reason -policyRevisionId -evaluatedAt -consensusDigest -``` - -Consensus persists only when Excititor policy adapters require pre-computed rollups (e.g., Offline Kit). Policy Engine can also compute consensus on demand from linksets. - -### 1.3 Exports & evidence bundles - -* **Raw observations** — JSON tree per observation for auditing/offline. -* **Linksets** — grouped evidence for policy/Console/CLI consumption. -* **Consensus (optional)** — if enabled, mirrors existing API contracts. -* **Provider snapshots** — last N days of observations per provider to support diagnostics. -* **Index** — `(productKey, vulnerabilityId) → {status candidates, confidence, observationIds}` for high-speed joins. - -All exports remain deterministic and, when configured, attested via DSSE + Rekor v2. - ---- - -## 3) Identity model — products & joins - -### 2.1 Vuln identity - -* Accepts **CVE**, **GHSA**, vendor IDs (MSRC, RHSA…), distro IDs (DSA/USN/RHSA…) — normalized to `vulnId` with alias sets. -* **Alias graph** maintained (from Concelier) to map vendor/distro IDs → CVE (primary) and to **GHSA** where applicable. - -### 2.2 Product identity (`productKey`) - -* **Primary:** `purl` (Package URL). -* **Secondary links:** `cpe`, **OS package NVRA/EVR**, NuGet/Maven/Golang identity, and **OS package name** when purl unavailable. -* **Fallback:** `oci:/@` for image‑level VEX. -* **Special cases:** kernel modules, firmware, platforms → provider‑specific mapping helpers (connector captures provider’s product taxonomy → canonical `productKey`). - -> Excititor does not invent identities. If a provider cannot be mapped to purl/CPE/NVRA deterministically, we keep the native **product string** and mark the claim as **non‑joinable**; the backend will ignore it unless a policy explicitly whitelists that provider mapping. - ---- - -## 4) Storage schema (MongoDB) - -Database: `excititor` - -### 3.1 Collections - -**`vex.providers`** - -``` -_id: providerId -name, homepage, contact -trustTier: enum {vendor, distro, platform, hub, attestation} -signaturePolicy: { type: pgp|cosign|x509|none, keys[], certs[], cosignKeylessRoots[] } -fetch: { baseUrl, kind: http|oci|file, rateLimit, etagSupport, windowDays } -enabled: bool -createdAt, modifiedAt -``` - -**`vex.raw`** (immutable raw documents) - -``` -_id: sha256(doc bytes) -providerId -uri -ingestedAt -contentType -sig: { verified: bool, method: pgp|cosign|x509|none, keyId|certSubject, bundle? } -payload: GridFS pointer (if large) -disposition: kept|replaced|superseded -correlation: { replaces?: sha256, replacedBy?: sha256 } -``` - -**`vex.observations`** - -``` -{ - _id: "tenant:providerId:upstreamId:revision", - tenant, - providerId, - streamId, - upstream: { upstreamId, documentVersion?, fetchedAt, receivedAt, contentHash, signature }, - statements: [ - { - vulnerabilityId, - productKey, - status, - justification?, - introducedVersion?, - fixedVersion?, - lastObserved, - locator?, - evidence? - } - ], - content: { format, specVersion?, raw }, - linkset: { aliases[], purls[], cpes[], references[], reconciledFrom[] }, - supersedes?, - createdAt, - attributes? -} -``` - - * Indexes: `{tenant:1, providerId:1, upstream.upstreamId:1}`, `{tenant:1, statements.vulnerabilityId:1}`, `{tenant:1, linkset.purls:1}`, `{tenant:1, createdAt:-1}`. - -**`vex.linksets`** - -``` -{ - _id: "sha256:...", - tenant, - key: { vulnerabilityId, productKey, confidence }, - observations: [ - { observationId, providerId, status, justification?, introducedVersion?, fixedVersion?, evidence?, collectedAt } - ], - aliases: { primary, others: [] }, - purls: [], - cpes: [], - conflicts: [], - createdAt, - updatedAt -} -``` - - * Indexes: `{tenant:1, key.vulnerabilityId:1, key.productKey:1}`, `{tenant:1, purls:1}`, `{tenant:1, updatedAt:-1}`. - -**`vex.events`** (observation/linkset events, optional long retention) - -``` -{ - _id: ObjectId, - tenant, - type: "vex.observation.updated" | "vex.linkset.updated", - key, - delta, - hash, - occurredAt -} -``` - - * Indexes: `{type:1, occurredAt:-1}`, TTL on `occurredAt` for configurable retention. - -**`vex.consensus`** (optional rollups) - -``` -_id: sha256(canonical(vulnerabilityId, productKey, policyRevisionId)) -vulnerabilityId -productKey -rollupStatus -sources[] // observation references with weights/reasons -policyRevisionId -evaluatedAt -signals? // optional severity/kev/epss hints -consensusDigest -``` - - * Indexes: `{vulnerabilityId:1, productKey:1}`, `{policyRevisionId:1, evaluatedAt:-1}`. - -**`vex.exports`** (manifest of emitted artifacts) - -``` -_id -querySignature -format: raw|consensus|index -artifactSha256 -rekor { uuid, index, url }? -createdAt -policyRevisionId -cacheable: bool -``` - -**`vex.cache`** — observation/linkset export cache: `{querySignature, exportId, ttl, hits}`. - -**`vex.migrations`** — ordered migrations ensuring new indexes (`20251027-linksets-introduced`, etc.). - -### 3.2 Indexing strategy - -* Hot path queries rely on `{tenant, key.vulnerabilityId, key.productKey}` covering linkset lookup. -* Observability queries use `{tenant, updatedAt}` to monitor staleness. -* Consensus (if enabled) keyed by `{vulnerabilityId, productKey, policyRevisionId}` for deterministic reuse. - ---- - -## 5) Ingestion pipeline - -### 4.1 Connector contract - -```csharp -public interface IVexConnector -{ - string ProviderId { get; } - Task FetchAsync(VexConnectorContext ctx, CancellationToken ct); // raw docs - Task NormalizeAsync(VexConnectorContext ctx, CancellationToken ct); // raw -> ObservationStatements[] -} -``` - -* **Fetch** must implement: window scheduling, conditional GET (ETag/If‑Modified‑Since), rate limiting, retry/backoff. -* **Normalize** parses the format, validates schema, maps product identities deterministically, emits observation statements with **provenance** metadata (locator, justification, version ranges). - -### 4.2 Signature verification (per provider) - -* **cosign (keyless or keyful)** for OCI referrers or HTTP‑served JSON with Sigstore bundles. -* **PGP** (provider keyrings) for distro/vendor feeds that sign docs. -* **x509** (mutual TLS / provider‑pinned certs) where applicable. -* Signature state is stored on **vex.raw.sig** and copied into `statements[].signatureState` so downstream policy can gate by verification result. - -> Observation statements from sources failing signature policy are marked `"signatureState.verified=false"` and policy can down-weight or ignore them. - -### 4.3 Time discipline - -* For each doc, prefer **provider’s document timestamp**; if absent, use fetch time. -* Statements carry `lastObserved` which drives **tie-breaking** within equal weight tiers. - ---- - -## 6) Normalization: product & status semantics - -### 5.1 Product mapping - -* **purl** first; **cpe** second; OS package NVRA/EVR mapping helpers (distro connectors) produce purls via canonical tables (e.g., rpm→purl:rpm, deb→purl:deb). -* Where a provider publishes **platform‑level** VEX (e.g., “RHEL 9 not affected”), connectors expand to known product inventory rules (e.g., map to sets of packages/components shipped in the platform). Expansion tables are versioned and kept per provider; every expansion emits **evidence** indicating the rule applied. -* If expansion would be speculative, the statement remains **platform-scoped** with `productKey="platform:redhat:rhel:9"` and is flagged **non-joinable**; backend can decide to use platform VEX only when Scanner proves the platform runtime. - -### 5.2 Status + justification mapping - -* Canonical **status**: `affected | not_affected | fixed | under_investigation`. -* **Justifications** normalized to a controlled vocabulary (CISA‑aligned), e.g.: - - * `component_not_present` - * `vulnerable_code_not_in_execute_path` - * `vulnerable_configuration_unused` - * `inline_mitigation_applied` - * `fix_available` (with `fixedVersion`) - * `under_investigation` -* Providers with free‑text justifications are mapped by deterministic tables; raw text preserved as `evidence`. - ---- - -## 7) Consensus algorithm - -**Goal:** produce a **stable**, explainable `rollupStatus` per `(vulnId, productKey)` when consumers opt into Excititor-managed consensus derived from linksets. - -### 6.1 Inputs - -* Set **S** of observation statements drawn from the current `VexLinkset` for `(tenant, vulnId, productKey)`. -* **Excititor policy snapshot**: - - * **weights** per provider tier and per provider overrides. - * **justification gates** (e.g., require justification for `not_affected` to be acceptable). - * **minEvidence** rules (e.g., `not_affected` must come from ≥1 vendor or 2 distros). - * **signature requirements** (e.g., require verified signature for ‘fixed’ to be considered). - -### 6.2 Steps - -1. **Filter invalid** statements by signature policy & justification gates → set `S'`. -2. **Score** each statement: - `score = weight(provider) * freshnessFactor(lastObserved)` where freshnessFactor ∈ [0.8, 1.0] for staleness decay (configurable; small effect). Observations lacking verified signatures receive policy-configured penalties. -3. **Aggregate** scores per status: `W(status) = Σ score(statements with that status)`. -4. **Pick** `rollupStatus = argmax_status W(status)`. -5. **Tie‑breakers** (in order): - - * Higher **max single** provider score wins (vendor > distro > platform > hub). - * More **recent** lastObserved wins. - * Deterministic lexicographic order of status (`fixed` > `not_affected` > `under_investigation` > `affected`) as final tiebreaker. -6. **Explain**: mark accepted observations (`accepted=true; reason="weight"`/`"freshness"`/`"confidence"`) and rejected ones with explicit `reason` (`"insufficient_justification"`, `"signature_unverified"`, `"lower_weight"`, `"low_confidence_linkset"`). - -> The algorithm is **pure** given `S` and policy snapshot; result is reproducible and hashed into `consensusDigest`. - ---- - -## 8) Query & export APIs - -All endpoints are versioned under `/api/v1/vex`. - -### 7.1 Query (online) - -``` -POST /observations/search - body: { vulnIds?: string[], productKeys?: string[], providers?: string[], since?: timestamp, limit?: int, pageToken?: string } - → { observations[], nextPageToken? } - -POST /linksets/search - body: { vulnIds?: string[], productKeys?: string[], confidence?: string[], since?: timestamp, limit?: int, pageToken?: string } - → { linksets[], nextPageToken? } - -POST /consensus/search - body: { vulnIds?: string[], productKeys?: string[], policyRevisionId?: string, since?: timestamp, limit?: int, pageToken?: string } - → { entries[], nextPageToken? } - -POST /excititor/resolve (scope: vex.read) - body: { productKeys?: string[], purls?: string[], vulnerabilityIds: string[], policyRevisionId?: string } - → { policy, resolvedAt, results: [ { vulnerabilityId, productKey, status, observations[], conflicts[], linksetConfidence, consensus?, signals?, envelope? } ] } -``` - -### 7.2 Exports (cacheable snapshots) - -``` -POST /exports - body: { signature: { vulnFilter?, productFilter?, providers?, since? }, format: raw|consensus|index, policyRevisionId?: string, force?: bool } - → { exportId, artifactSha256, rekor? } - -GET /exports/{exportId} → bytes (application/json or binary index) -GET /exports/{exportId}/meta → { signature, policyRevisionId, createdAt, artifactSha256, rekor? } -``` - -### 7.3 Provider operations - -``` -GET /providers → provider list & signature policy -POST /providers/{id}/refresh → trigger fetch/normalize window -GET /providers/{id}/status → last fetch, doc counts, signature stats -``` - -**Auth:** service‑to‑service via Authority tokens; operator operations via UI/CLI with RBAC. - ---- - -## 9) Attestation integration - -* Exports can be **DSSE‑signed** via **Signer** and logged to **Rekor v2** via **Attestor** (optional but recommended for regulated pipelines). -* `vex.exports.rekor` stores `{uuid, index, url}` when present. -* **Predicate type**: `https://stella-ops.org/attestations/vex-export/1` with fields: - - * `querySignature`, `policyRevisionId`, `artifactSha256`, `createdAt`. - ---- - -## 10) Configuration (YAML) - -```yaml -excititor: - mongo: { uri: "mongodb://mongo/excititor" } - s3: - endpoint: http://minio:9000 - bucket: stellaops - policy: - weights: - vendor: 1.0 - distro: 0.9 - platform: 0.7 - hub: 0.5 - attestation: 0.6 - ceiling: 1.25 - scoring: - alpha: 0.25 - beta: 0.5 - providerOverrides: - redhat: 1.0 - suse: 0.95 - requireJustificationForNotAffected: true - signatureRequiredForFixed: true - minEvidence: - not_affected: - vendorOrTwoDistros: true - connectors: - - providerId: redhat - kind: csaf - baseUrl: https://access.redhat.com/security/data/csaf/v2/ - signaturePolicy: { type: pgp, keys: [ "…redhat-pgp-key…" ] } - windowDays: 7 - - providerId: suse - kind: csaf - baseUrl: https://ftp.suse.com/pub/projects/security/csaf/ - signaturePolicy: { type: pgp, keys: [ "…suse-pgp-key…" ] } - - providerId: ubuntu - kind: openvex - baseUrl: https://…/vex/ - signaturePolicy: { type: none } - - providerId: vendorX - kind: cyclonedx-vex - ociRef: ghcr.io/vendorx/vex@sha256:… - signaturePolicy: { type: cosign, cosignKeylessRoots: [ "sigstore-root" ] } -``` - -### 9.1 WebService endpoints - -With storage configured, the WebService exposes the following ingress and diagnostic APIs: - -* `GET /excititor/status` – returns the active storage configuration and registered artifact stores. -* `GET /excititor/health` – simple liveness probe. -* `POST /excititor/statements` – accepts normalized VEX statements and persists them via `IVexClaimStore`; use this for migrations/backfills. -* `GET /excititor/statements/{vulnId}/{productKey}?since=` – returns the immutable statement log for a vulnerability/product pair. -* `POST /excititor/resolve` – requires `vex.read` scope; accepts up to 256 `(vulnId, productKey)` pairs via `productKeys` or `purls` and returns deterministic consensus results, decision telemetry, and a signed envelope (`artifact` digest, optional signer signature, optional attestation metadata + DSSE envelope). Returns **409 Conflict** when the requested `policyRevisionId` mismatches the active snapshot. - -Run the ingestion endpoint once after applying migration `20251019-consensus-signals-statements` to repopulate historical statements with the new severity/KEV/EPSS signal fields. - -* `weights.ceiling` raises the deterministic clamp applied to provider tiers/overrides (range 1.0‒5.0). Values outside the range are clamped with warnings so operators can spot typos. -* `scoring.alpha` / `scoring.beta` configure KEV/EPSS boosts for the Phase 1 → Phase 2 scoring pipeline. Defaults (0.25, 0.5) preserve prior behaviour; negative or excessively large values fall back with diagnostics. - ---- - -## 11) Security model - -* **Input signature verification** enforced per provider policy (PGP, cosign, x509). -* **Connector allowlists**: outbound fetch constrained to configured domains. -* **Tenant isolation**: per‑tenant DB prefixes or separate DBs; per‑tenant S3 prefixes; per‑tenant policies. -* **AuthN/Z**: Authority‑issued OpToks; RBAC roles (`vex.read`, `vex.admin`, `vex.export`). -* **No secrets in logs**; deterministic logging contexts include providerId, docDigest, observationId, and linksetId. - ---- - -## 12) Performance & scale - -* **Targets:** - - * Normalize 10k observation statements/minute/core. - * Linkset rebuild ≤ 20 ms P95 for 1k unique `(vuln, product)` pairs in hot cache. - * Consensus (when enabled) compute ≤ 50 ms for 1k unique `(vuln, product)` pairs. - * Export (observations + linksets) 1M rows in ≤ 60 s on 8 cores with streaming writer. - -* **Scaling:** - - * WebService handles control APIs; **Worker** background services (same image) execute fetch/normalize in parallel with rate‑limits; Mongo writes batched; upserts by natural keys. - * Exports stream straight to S3 (MinIO) with rolling buffers. - -* **Caching:** - - * `vex.cache` maps query signatures → export; TTL to avoid stampedes; optimistic reuse unless `force`. - -### 11.1 Worker TTL refresh controls - -Excititor.Worker ships with a background refresh service that re-evaluates stale consensus rows and applies stability dampers before publishing status flips. Operators can tune its behaviour through the following configuration (shown in `appsettings.json` syntax): - -```jsonc -{ - "Excititor": { - "Worker": { - "Refresh": { - "Enabled": true, - "ConsensusTtl": "02:00:00", // refresh consensus older than 2 hours - "ScanInterval": "00:10:00", // sweep cadence - "ScanBatchSize": 250, // max documents examined per sweep - "Damper": { - "Minimum": "1.00:00:00", // lower bound before status flip publishes - "Maximum": "2.00:00:00", // upper bound guardrail - "DefaultDuration": "1.12:00:00", - "Rules": [ - { "MinWeight": 0.90, "Duration": "1.00:00:00" }, - { "MinWeight": 0.75, "Duration": "1.06:00:00" }, - { "MinWeight": 0.50, "Duration": "1.12:00:00" } - ] - } - } - } - } -} -``` - -* `ConsensusTtl` governs when the worker issues a fresh resolve for cached consensus data. -* `Damper` lengths are clamped between `Minimum`/`Maximum`; duration is bypassed when component fingerprints (`VexProduct.ComponentIdentifiers`) change. -* The same keys are available through environment variables (e.g., `Excititor__Worker__Refresh__ConsensusTtl=02:00:00`). - ---- - -## 13) Observability - -* **Metrics:** - - * `vex.fetch.requests_total{provider}` / `vex.fetch.bytes_total{provider}` - * `vex.fetch.failures_total{provider,reason}` / `vex.signature.failures_total{provider,method}` - * `vex.normalize.statements_total{provider}` - * `vex.observations.write_total{result}` - * `vex.linksets.updated_total{result}` / `vex.linksets.conflicts_total{type}` - * `vex.consensus.rollup_total{status}` (when enabled) - * `vex.exports.bytes_total{format}` / `vex.exports.latency_seconds{format}` -* **Tracing:** spans for fetch, verify, parse, map, observe, linkset, consensus, export. -* **Dashboards:** provider staleness, linkset conflict hot spots, signature posture, export cache hit-rate. - ---- - -## 14) Testing matrix - -* **Connectors:** golden raw docs → deterministic observation statements (fixtures per provider/format). -* **Signature policies:** valid/invalid PGP/cosign/x509 samples; ensure rejects are recorded but not accepted. -* **Normalization edge cases:** platform-scoped statements, free-text justifications, non-purl products. -* **Linksets:** conflict scenarios across tiers; verify confidence scoring + conflict payload stability. -* **Consensus (optional):** ensure tie-breakers honour policy weights/justification gates. -* **Performance:** 1M-row observation/linkset export timing; memory ceilings; stream correctness. -* **Determinism:** same inputs + policy → identical linkset hashes, conflict payloads, optional `consensusDigest`, and export bytes. -* **API contract tests:** pagination, filters, RBAC, rate limits. - ---- - -## 15) Integration points - -* **Backend Policy Engine** (in Scanner.WebService): calls `POST /excititor/resolve` (scope `vex.read`) with batched `(purl, vulnId)` pairs to fetch `rollupStatus + sources`. -* **Concelier**: provides alias graph (CVE↔vendor IDs) and may supply VEX‑adjacent metadata (e.g., KEV flag) for policy escalation. -* **UI**: VEX explorer screens use `/observations/search`, `/linksets/search`, and `/consensus/search`; show conflicts & provenance. -* **CLI**: `stella vex linksets export --since 7d --out vex-linksets.json` (optionally `--include-consensus`) for audits and Offline Kit parity. - ---- - -## 16) Failure modes & fallback - -* **Provider unreachable:** stale thresholds trigger warnings; policy can down‑weight stale providers automatically (freshness factor). -* **Signature outage:** continue to ingest but mark `signatureState.verified=false`; consensus will likely exclude or down‑weight per policy. -* **Schema drift:** unknown fields are preserved as `evidence`; normalization rejects only on **invalid identity** or **status**. - ---- - -## 17) Rollout plan (incremental) - -1. **MVP**: OpenVEX + CSAF connectors for 3 major providers (e.g., Red Hat/SUSE/Ubuntu), normalization + consensus + `/excititor/resolve`. -2. **Signature policies**: PGP for distros; cosign for OCI. -3. **Exports + optional attestation**. -4. **CycloneDX VEX** connectors; platform claim expansion tables; UI explorer. -5. **Scale hardening**: export indexes; conflict analytics. - ---- - -## 18) Operational runbooks - -* **Statement backfill** — see `docs/dev/EXCITITOR_STATEMENT_BACKFILL.md` for the CLI workflow, required permissions, observability guidance, and rollback steps. - ---- - -## 19) Appendix — canonical JSON (stable ordering) - -All exports and consensus entries are serialized via `VexCanonicalJsonSerializer`: - -* UTF‑8 without BOM; -* keys sorted (ASCII); -* arrays sorted by `(providerId, vulnId, productKey, lastObserved)` unless semantic order mandated; -* timestamps in `YYYY‑MM‑DDThh:mm:ssZ`; -* no insignificant whitespace. - + +### 1.4 AI-ready citations + +`GET /v1/vex/statements/{advisory_key}` produces sorted JSON responses containing raw statement metadata (`issuer`, `content_hash`, `signature`), normalised tuples, and provenance pointers. Advisory AI consumes this endpoint to build retrieval contexts with explicit citations. + +--- + +## 2) Inputs, outputs & canonical domain + +### 1.1 Accepted input formats (ingest) + +* **OpenVEX** JSON documents (attested or raw). +* **CSAF VEX** 2.x (vendor PSIRTs and distros commonly publish CSAF). +* **CycloneDX VEX** 1.4+ (standalone VEX or embedded VEX blocks). +* **OCI‑attached attestations** (VEX statements shipped as OCI referrers) — optional connectors. + +All connectors register **source metadata**: provider identity, trust tier, signature expectations (PGP/cosign/PKI), fetch windows, rate limits, and time anchors. + +### 1.2 Canonical model (observations & linksets) + +#### VexObservation + +```jsonc +observationId // {tenant}:{providerId}:{upstreamId}:{revision} +tenant +providerId // e.g., redhat, suse, ubuntu, osv +streamId // connector stream (csaf, openvex, cyclonedx, attestation) +upstream{ + upstreamId, + documentVersion?, + fetchedAt, + receivedAt, + contentHash, + signature{present, format?, keyId?, signature?} +} +statements[ + { + vulnerabilityId, + productKey, + status, // affected | not_affected | fixed | under_investigation + justification?, + introducedVersion?, + fixedVersion?, + lastObserved, + locator?, // JSON Pointer/line for provenance + evidence?[] + } +] +content{ + format, + specVersion?, + raw +} +linkset{ + aliases[], // CVE/GHSA/vendor IDs + purls[], + cpes[], + references[{type,url}], + reconciledFrom[] +} +supersedes? +createdAt +attributes? +``` + +#### VexLinkset + +```jsonc +linksetId // sha256 over sorted (tenant, vulnId, productKey, observationIds) +tenant +key{ + vulnerabilityId, + productKey, + confidence // low|medium|high +} +observations[] = [ + { + observationId, + providerId, + status, + justification?, + introducedVersion?, + fixedVersion?, + evidence?, + collectedAt + } +] +aliases{ + primary, + others[] +} +purls[] +cpes[] +conflicts[]? // see VexLinksetConflict +createdAt +updatedAt +``` + +#### VexLinksetConflict + +```jsonc +conflictId +type // status-mismatch | justification-divergence | version-range-clash | non-joinable-overlap | metadata-gap +field? // optional pointer for UI rendering +statements[] // per-observation values with providerId + status/justification/version data +confidence +detectedAt +``` + +#### VexConsensus (optional) + +```jsonc +consensusId // sha256(vulnerabilityId, productKey, policyRevisionId) +vulnerabilityId +productKey +rollupStatus // derived by Excititor policy adapter (linkset aware) +sources[] // observation references with weight, accepted flag, reason +policyRevisionId +evaluatedAt +consensusDigest +``` + +Consensus persists only when Excititor policy adapters require pre-computed rollups (e.g., Offline Kit). Policy Engine can also compute consensus on demand from linksets. + +### 1.3 Exports & evidence bundles + +* **Raw observations** — JSON tree per observation for auditing/offline. +* **Linksets** — grouped evidence for policy/Console/CLI consumption. +* **Consensus (optional)** — if enabled, mirrors existing API contracts. +* **Provider snapshots** — last N days of observations per provider to support diagnostics. +* **Index** — `(productKey, vulnerabilityId) → {status candidates, confidence, observationIds}` for high-speed joins. + +All exports remain deterministic and, when configured, attested via DSSE + Rekor v2. + +--- + +## 3) Identity model — products & joins + +### 2.1 Vuln identity + +* Accepts **CVE**, **GHSA**, vendor IDs (MSRC, RHSA…), distro IDs (DSA/USN/RHSA…) — normalized to `vulnId` with alias sets. +* **Alias graph** maintained (from Concelier) to map vendor/distro IDs → CVE (primary) and to **GHSA** where applicable. + +### 2.2 Product identity (`productKey`) + +* **Primary:** `purl` (Package URL). +* **Secondary links:** `cpe`, **OS package NVRA/EVR**, NuGet/Maven/Golang identity, and **OS package name** when purl unavailable. +* **Fallback:** `oci:/@` for image‑level VEX. +* **Special cases:** kernel modules, firmware, platforms → provider‑specific mapping helpers (connector captures provider’s product taxonomy → canonical `productKey`). + +> Excititor does not invent identities. If a provider cannot be mapped to purl/CPE/NVRA deterministically, we keep the native **product string** and mark the claim as **non‑joinable**; the backend will ignore it unless a policy explicitly whitelists that provider mapping. + +--- + +## 4) Storage schema (MongoDB) + +Database: `excititor` + +### 3.1 Collections + +**`vex.providers`** + +``` +_id: providerId +name, homepage, contact +trustTier: enum {vendor, distro, platform, hub, attestation} +signaturePolicy: { type: pgp|cosign|x509|none, keys[], certs[], cosignKeylessRoots[] } +fetch: { baseUrl, kind: http|oci|file, rateLimit, etagSupport, windowDays } +enabled: bool +createdAt, modifiedAt +``` + +**`vex.raw`** (immutable raw documents) + +``` +_id: sha256(doc bytes) +providerId +uri +ingestedAt +contentType +sig: { verified: bool, method: pgp|cosign|x509|none, keyId|certSubject, bundle? } +payload: GridFS pointer (if large) +disposition: kept|replaced|superseded +correlation: { replaces?: sha256, replacedBy?: sha256 } +``` + +**`vex.observations`** + +``` +{ + _id: "tenant:providerId:upstreamId:revision", + tenant, + providerId, + streamId, + upstream: { upstreamId, documentVersion?, fetchedAt, receivedAt, contentHash, signature }, + statements: [ + { + vulnerabilityId, + productKey, + status, + justification?, + introducedVersion?, + fixedVersion?, + lastObserved, + locator?, + evidence? + } + ], + content: { format, specVersion?, raw }, + linkset: { aliases[], purls[], cpes[], references[], reconciledFrom[] }, + supersedes?, + createdAt, + attributes? +} +``` + + * Indexes: `{tenant:1, providerId:1, upstream.upstreamId:1}`, `{tenant:1, statements.vulnerabilityId:1}`, `{tenant:1, linkset.purls:1}`, `{tenant:1, createdAt:-1}`. + +**`vex.linksets`** + +``` +{ + _id: "sha256:...", + tenant, + key: { vulnerabilityId, productKey, confidence }, + observations: [ + { observationId, providerId, status, justification?, introducedVersion?, fixedVersion?, evidence?, collectedAt } + ], + aliases: { primary, others: [] }, + purls: [], + cpes: [], + conflicts: [], + createdAt, + updatedAt +} +``` + + * Indexes: `{tenant:1, key.vulnerabilityId:1, key.productKey:1}`, `{tenant:1, purls:1}`, `{tenant:1, updatedAt:-1}`. + +**`vex.events`** (observation/linkset events, optional long retention) + +``` +{ + _id: ObjectId, + tenant, + type: "vex.observation.updated" | "vex.linkset.updated", + key, + delta, + hash, + occurredAt +} +``` + + * Indexes: `{type:1, occurredAt:-1}`, TTL on `occurredAt` for configurable retention. + +**`vex.consensus`** (optional rollups) + +``` +_id: sha256(canonical(vulnerabilityId, productKey, policyRevisionId)) +vulnerabilityId +productKey +rollupStatus +sources[] // observation references with weights/reasons +policyRevisionId +evaluatedAt +signals? // optional severity/kev/epss hints +consensusDigest +``` + + * Indexes: `{vulnerabilityId:1, productKey:1}`, `{policyRevisionId:1, evaluatedAt:-1}`. + +**`vex.exports`** (manifest of emitted artifacts) + +``` +_id +querySignature +format: raw|consensus|index +artifactSha256 +rekor { uuid, index, url }? +createdAt +policyRevisionId +cacheable: bool +``` + +**`vex.cache`** — observation/linkset export cache: `{querySignature, exportId, ttl, hits}`. + +**`vex.migrations`** — ordered migrations ensuring new indexes (`20251027-linksets-introduced`, etc.). + +### 3.2 Indexing strategy + +* Hot path queries rely on `{tenant, key.vulnerabilityId, key.productKey}` covering linkset lookup. +* Observability queries use `{tenant, updatedAt}` to monitor staleness. +* Consensus (if enabled) keyed by `{vulnerabilityId, productKey, policyRevisionId}` for deterministic reuse. + +--- + +## 5) Ingestion pipeline + +### 4.1 Connector contract + +```csharp +public interface IVexConnector +{ + string ProviderId { get; } + Task FetchAsync(VexConnectorContext ctx, CancellationToken ct); // raw docs + Task NormalizeAsync(VexConnectorContext ctx, CancellationToken ct); // raw -> ObservationStatements[] +} +``` + +* **Fetch** must implement: window scheduling, conditional GET (ETag/If‑Modified‑Since), rate limiting, retry/backoff. +* **Normalize** parses the format, validates schema, maps product identities deterministically, emits observation statements with **provenance** metadata (locator, justification, version ranges). + +### 4.2 Signature verification (per provider) + +* **cosign (keyless or keyful)** for OCI referrers or HTTP‑served JSON with Sigstore bundles. +* **PGP** (provider keyrings) for distro/vendor feeds that sign docs. +* **x509** (mutual TLS / provider‑pinned certs) where applicable. +* Signature state is stored on **vex.raw.sig** and copied into `statements[].signatureState` so downstream policy can gate by verification result. + +> Observation statements from sources failing signature policy are marked `"signatureState.verified=false"` and policy can down-weight or ignore them. + +### 4.3 Time discipline + +* For each doc, prefer **provider’s document timestamp**; if absent, use fetch time. +* Statements carry `lastObserved` which drives **tie-breaking** within equal weight tiers. + +--- + +## 6) Normalization: product & status semantics + +### 5.1 Product mapping + +* **purl** first; **cpe** second; OS package NVRA/EVR mapping helpers (distro connectors) produce purls via canonical tables (e.g., rpm→purl:rpm, deb→purl:deb). +* Where a provider publishes **platform‑level** VEX (e.g., “RHEL 9 not affected”), connectors expand to known product inventory rules (e.g., map to sets of packages/components shipped in the platform). Expansion tables are versioned and kept per provider; every expansion emits **evidence** indicating the rule applied. +* If expansion would be speculative, the statement remains **platform-scoped** with `productKey="platform:redhat:rhel:9"` and is flagged **non-joinable**; backend can decide to use platform VEX only when Scanner proves the platform runtime. + +### 5.2 Status + justification mapping + +* Canonical **status**: `affected | not_affected | fixed | under_investigation`. +* **Justifications** normalized to a controlled vocabulary (CISA‑aligned), e.g.: + + * `component_not_present` + * `vulnerable_code_not_in_execute_path` + * `vulnerable_configuration_unused` + * `inline_mitigation_applied` + * `fix_available` (with `fixedVersion`) + * `under_investigation` +* Providers with free‑text justifications are mapped by deterministic tables; raw text preserved as `evidence`. + +--- + +## 7) Consensus algorithm + +**Goal:** produce a **stable**, explainable `rollupStatus` per `(vulnId, productKey)` when consumers opt into Excititor-managed consensus derived from linksets. + +### 6.1 Inputs + +* Set **S** of observation statements drawn from the current `VexLinkset` for `(tenant, vulnId, productKey)`. +* **Excititor policy snapshot**: + + * **weights** per provider tier and per provider overrides. + * **justification gates** (e.g., require justification for `not_affected` to be acceptable). + * **minEvidence** rules (e.g., `not_affected` must come from ≥1 vendor or 2 distros). + * **signature requirements** (e.g., require verified signature for ‘fixed’ to be considered). + +### 6.2 Steps + +1. **Filter invalid** statements by signature policy & justification gates → set `S'`. +2. **Score** each statement: + `score = weight(provider) * freshnessFactor(lastObserved)` where freshnessFactor ∈ [0.8, 1.0] for staleness decay (configurable; small effect). Observations lacking verified signatures receive policy-configured penalties. +3. **Aggregate** scores per status: `W(status) = Σ score(statements with that status)`. +4. **Pick** `rollupStatus = argmax_status W(status)`. +5. **Tie‑breakers** (in order): + + * Higher **max single** provider score wins (vendor > distro > platform > hub). + * More **recent** lastObserved wins. + * Deterministic lexicographic order of status (`fixed` > `not_affected` > `under_investigation` > `affected`) as final tiebreaker. +6. **Explain**: mark accepted observations (`accepted=true; reason="weight"`/`"freshness"`/`"confidence"`) and rejected ones with explicit `reason` (`"insufficient_justification"`, `"signature_unverified"`, `"lower_weight"`, `"low_confidence_linkset"`). + +> The algorithm is **pure** given `S` and policy snapshot; result is reproducible and hashed into `consensusDigest`. + +--- + +## 8) Query & export APIs + +All endpoints are versioned under `/api/v1/vex`. + +### 7.1 Query (online) + +``` +POST /observations/search + body: { vulnIds?: string[], productKeys?: string[], providers?: string[], since?: timestamp, limit?: int, pageToken?: string } + → { observations[], nextPageToken? } + +POST /linksets/search + body: { vulnIds?: string[], productKeys?: string[], confidence?: string[], since?: timestamp, limit?: int, pageToken?: string } + → { linksets[], nextPageToken? } + +POST /consensus/search + body: { vulnIds?: string[], productKeys?: string[], policyRevisionId?: string, since?: timestamp, limit?: int, pageToken?: string } + → { entries[], nextPageToken? } + +POST /excititor/resolve (scope: vex.read) + body: { productKeys?: string[], purls?: string[], vulnerabilityIds: string[], policyRevisionId?: string } + → { policy, resolvedAt, results: [ { vulnerabilityId, productKey, status, observations[], conflicts[], linksetConfidence, consensus?, signals?, envelope? } ] } +``` + +### 7.2 Exports (cacheable snapshots) + +``` +POST /exports + body: { signature: { vulnFilter?, productFilter?, providers?, since? }, format: raw|consensus|index, policyRevisionId?: string, force?: bool } + → { exportId, artifactSha256, rekor? } + +GET /exports/{exportId} → bytes (application/json or binary index) +GET /exports/{exportId}/meta → { signature, policyRevisionId, createdAt, artifactSha256, rekor? } +``` + +### 7.3 Provider operations + +``` +GET /providers → provider list & signature policy +POST /providers/{id}/refresh → trigger fetch/normalize window +GET /providers/{id}/status → last fetch, doc counts, signature stats +``` + +**Auth:** service‑to‑service via Authority tokens; operator operations via UI/CLI with RBAC. + +--- + +## 9) Attestation integration + +* Exports can be **DSSE‑signed** via **Signer** and logged to **Rekor v2** via **Attestor** (optional but recommended for regulated pipelines). +* `vex.exports.rekor` stores `{uuid, index, url}` when present. +* **Predicate type**: `https://stella-ops.org/attestations/vex-export/1` with fields: + + * `querySignature`, `policyRevisionId`, `artifactSha256`, `createdAt`. + +--- + +## 10) Configuration (YAML) + +```yaml +excititor: + mongo: { uri: "mongodb://mongo/excititor" } + s3: + endpoint: http://minio:9000 + bucket: stellaops + policy: + weights: + vendor: 1.0 + distro: 0.9 + platform: 0.7 + hub: 0.5 + attestation: 0.6 + ceiling: 1.25 + scoring: + alpha: 0.25 + beta: 0.5 + providerOverrides: + redhat: 1.0 + suse: 0.95 + requireJustificationForNotAffected: true + signatureRequiredForFixed: true + minEvidence: + not_affected: + vendorOrTwoDistros: true + connectors: + - providerId: redhat + kind: csaf + baseUrl: https://access.redhat.com/security/data/csaf/v2/ + signaturePolicy: { type: pgp, keys: [ "…redhat-pgp-key…" ] } + windowDays: 7 + - providerId: suse + kind: csaf + baseUrl: https://ftp.suse.com/pub/projects/security/csaf/ + signaturePolicy: { type: pgp, keys: [ "…suse-pgp-key…" ] } + - providerId: ubuntu + kind: openvex + baseUrl: https://…/vex/ + signaturePolicy: { type: none } + - providerId: vendorX + kind: cyclonedx-vex + ociRef: ghcr.io/vendorx/vex@sha256:… + signaturePolicy: { type: cosign, cosignKeylessRoots: [ "sigstore-root" ] } +``` + +### 9.1 WebService endpoints + +With storage configured, the WebService exposes the following ingress and diagnostic APIs: + +* `GET /excititor/status` – returns the active storage configuration and registered artifact stores. +* `GET /excititor/health` – simple liveness probe. +* `POST /excititor/statements` – accepts normalized VEX statements and persists them via `IVexClaimStore`; use this for migrations/backfills. +* `GET /excititor/statements/{vulnId}/{productKey}?since=` – returns the immutable statement log for a vulnerability/product pair. +* `POST /excititor/resolve` – requires `vex.read` scope; accepts up to 256 `(vulnId, productKey)` pairs via `productKeys` or `purls` and returns deterministic consensus results, decision telemetry, and a signed envelope (`artifact` digest, optional signer signature, optional attestation metadata + DSSE envelope). Returns **409 Conflict** when the requested `policyRevisionId` mismatches the active snapshot. + +Run the ingestion endpoint once after applying migration `20251019-consensus-signals-statements` to repopulate historical statements with the new severity/KEV/EPSS signal fields. + +* `weights.ceiling` raises the deterministic clamp applied to provider tiers/overrides (range 1.0‒5.0). Values outside the range are clamped with warnings so operators can spot typos. +* `scoring.alpha` / `scoring.beta` configure KEV/EPSS boosts for the Phase 1 → Phase 2 scoring pipeline. Defaults (0.25, 0.5) preserve prior behaviour; negative or excessively large values fall back with diagnostics. + +--- + +## 11) Security model + +* **Input signature verification** enforced per provider policy (PGP, cosign, x509). +* **Connector allowlists**: outbound fetch constrained to configured domains. +* **Tenant isolation**: per‑tenant DB prefixes or separate DBs; per‑tenant S3 prefixes; per‑tenant policies. +* **AuthN/Z**: Authority‑issued OpToks; RBAC roles (`vex.read`, `vex.admin`, `vex.export`). +* **No secrets in logs**; deterministic logging contexts include providerId, docDigest, observationId, and linksetId. + +--- + +## 12) Performance & scale + +* **Targets:** + + * Normalize 10k observation statements/minute/core. + * Linkset rebuild ≤ 20 ms P95 for 1k unique `(vuln, product)` pairs in hot cache. + * Consensus (when enabled) compute ≤ 50 ms for 1k unique `(vuln, product)` pairs. + * Export (observations + linksets) 1M rows in ≤ 60 s on 8 cores with streaming writer. + +* **Scaling:** + + * WebService handles control APIs; **Worker** background services (same image) execute fetch/normalize in parallel with rate‑limits; Mongo writes batched; upserts by natural keys. + * Exports stream straight to S3 (MinIO) with rolling buffers. + +* **Caching:** + + * `vex.cache` maps query signatures → export; TTL to avoid stampedes; optimistic reuse unless `force`. + +### 11.1 Worker TTL refresh controls + +Excititor.Worker ships with a background refresh service that re-evaluates stale consensus rows and applies stability dampers before publishing status flips. Operators can tune its behaviour through the following configuration (shown in `appsettings.json` syntax): + +```jsonc +{ + "Excititor": { + "Worker": { + "Refresh": { + "Enabled": true, + "ConsensusTtl": "02:00:00", // refresh consensus older than 2 hours + "ScanInterval": "00:10:00", // sweep cadence + "ScanBatchSize": 250, // max documents examined per sweep + "Damper": { + "Minimum": "1.00:00:00", // lower bound before status flip publishes + "Maximum": "2.00:00:00", // upper bound guardrail + "DefaultDuration": "1.12:00:00", + "Rules": [ + { "MinWeight": 0.90, "Duration": "1.00:00:00" }, + { "MinWeight": 0.75, "Duration": "1.06:00:00" }, + { "MinWeight": 0.50, "Duration": "1.12:00:00" } + ] + } + } + } + } +} +``` + +* `ConsensusTtl` governs when the worker issues a fresh resolve for cached consensus data. +* `Damper` lengths are clamped between `Minimum`/`Maximum`; duration is bypassed when component fingerprints (`VexProduct.ComponentIdentifiers`) change. +* The same keys are available through environment variables (e.g., `Excititor__Worker__Refresh__ConsensusTtl=02:00:00`). + +--- + +## 13) Observability + +* **Metrics:** + + * `vex.fetch.requests_total{provider}` / `vex.fetch.bytes_total{provider}` + * `vex.fetch.failures_total{provider,reason}` / `vex.signature.failures_total{provider,method}` + * `vex.normalize.statements_total{provider}` + * `vex.observations.write_total{result}` + * `vex.linksets.updated_total{result}` / `vex.linksets.conflicts_total{type}` + * `vex.consensus.rollup_total{status}` (when enabled) + * `vex.exports.bytes_total{format}` / `vex.exports.latency_seconds{format}` +* **Tracing:** spans for fetch, verify, parse, map, observe, linkset, consensus, export. +* **Dashboards:** provider staleness, linkset conflict hot spots, signature posture, export cache hit-rate. +* **Telemetry configuration:** `Excititor:Telemetry` toggles OpenTelemetry for the host (`Enabled`, `EnableTracing`, `EnableMetrics`, `ServiceName`, `OtlpEndpoint`, optional `OtlpHeaders` and `ResourceAttributes`). Point it at the collector profile listed in `docs/observability/observability.md` so Excititor’s `ingestion_*` metrics land in the same Grafana dashboards as Concelier. +* **Health endpoint:** `/obs/excititor/health` (scope `vex.admin`) surfaces ingest/link/signature/conflict SLOs for Console + Grafana. Thresholds are configurable via `Excititor:Observability:*` (see `docs/observability/observability.md`). +* **Local replica set:** `tools/mongodb/local-mongo.sh start` downloads the vetted MongoDB binaries (6.0.x), boots a `rs0` single-node replica set, and prints the `EXCITITOR_TEST_MONGO_URI` export line so storage/integration tests can bypass Mongo2Go. `restart` restarts in-place, `clean` wipes the managed data/logs for deterministic runs, and `stop/status/logs` cover teardown/inspection. +* **API headers:** responses echo `X-Stella-TraceId` and `X-Stella-CorrelationId` to keep Console/Loki links deterministic; inbound correlation headers are preserved when present. + +--- + +## 14) Testing matrix + +* **Connectors:** golden raw docs → deterministic observation statements (fixtures per provider/format). +* **Signature policies:** valid/invalid PGP/cosign/x509 samples; ensure rejects are recorded but not accepted. +* **Normalization edge cases:** platform-scoped statements, free-text justifications, non-purl products. +* **Linksets:** conflict scenarios across tiers; verify confidence scoring + conflict payload stability. +* **Consensus (optional):** ensure tie-breakers honour policy weights/justification gates. +* **Batch ingest validation:** `dotnet test src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj --filter "Category=BatchIngestValidation"` ingests mixed CycloneDX/CSAF/OpenVEX fixtures, asserts `/vex/raw` parity, confirms `ingestion_write_total` tags, and checks `/aoc/verify` output—run after touching ingest/telemetry code. +* **Performance:** 1M-row observation/linkset export timing; memory ceilings; stream correctness. +* **Determinism:** same inputs + policy → identical linkset hashes, conflict payloads, optional `consensusDigest`, and export bytes. +* **API contract tests:** pagination, filters, RBAC, rate limits. + +--- + +## 15) Integration points + +* **Backend Policy Engine** (in Scanner.WebService): calls `POST /excititor/resolve` (scope `vex.read`) with batched `(purl, vulnId)` pairs to fetch `rollupStatus + sources`. +* **Concelier**: provides alias graph (CVE↔vendor IDs) and may supply VEX‑adjacent metadata (e.g., KEV flag) for policy escalation. +* **UI**: VEX explorer screens use `/observations/search`, `/linksets/search`, and `/consensus/search`; show conflicts & provenance. +* **CLI**: `stella vex linksets export --since 7d --out vex-linksets.json` (optionally `--include-consensus`) for audits and Offline Kit parity. + +--- + +## 16) Failure modes & fallback + +* **Provider unreachable:** stale thresholds trigger warnings; policy can down‑weight stale providers automatically (freshness factor). +* **Signature outage:** continue to ingest but mark `signatureState.verified=false`; consensus will likely exclude or down‑weight per policy. +* **Schema drift:** unknown fields are preserved as `evidence`; normalization rejects only on **invalid identity** or **status**. + +--- + +## 17) Rollout plan (incremental) + +1. **MVP**: OpenVEX + CSAF connectors for 3 major providers (e.g., Red Hat/SUSE/Ubuntu), normalization + consensus + `/excititor/resolve`. +2. **Signature policies**: PGP for distros; cosign for OCI. +3. **Exports + optional attestation**. +4. **CycloneDX VEX** connectors; platform claim expansion tables; UI explorer. +5. **Scale hardening**: export indexes; conflict analytics. + +--- + +## 18) Operational runbooks + +* **Statement backfill** — see `docs/dev/EXCITITOR_STATEMENT_BACKFILL.md` for the CLI workflow, required permissions, observability guidance, and rollback steps. + +--- + +## 19) Appendix — canonical JSON (stable ordering) + +All exports and consensus entries are serialized via `VexCanonicalJsonSerializer`: + +* UTF‑8 without BOM; +* keys sorted (ASCII); +* arrays sorted by `(providerId, vulnId, productKey, lastObserved)` unless semantic order mandated; +* timestamps in `YYYY‑MM‑DDThh:mm:ssZ`; +* no insignificant whitespace. diff --git a/docs/modules/findings-ledger/workflow-inference.md b/docs/modules/findings-ledger/workflow-inference.md new file mode 100644 index 000000000..ceb350f0a --- /dev/null +++ b/docs/modules/findings-ledger/workflow-inference.md @@ -0,0 +1,30 @@ +# Findings Ledger — Workflow Inference Notes + +> **Audience:** Findings Ledger Guild, Vuln Explorer API, Console Guild +> **Scope:** How workflow mutations (assign/comment/accept-risk/etc.) derive canonical ledger inputs when the caller omits low-level fields. + +## 1. Chain + sequencing +- **Chain derivation.** When a request does not provide `chainId`, we deterministically derive it from `tenantId :: policyVersion` (`Guid` from SHA-256 first 16 bytes). This keeps per-policy ordering stable and avoids callers leaking raw GUID logic. +- **Sequence number.** We fetch the current chain head and expect `head.sequence + 1`. If the chain has no entries, the expected sequence is `1`. The append path still enforces optimistic concurrency and rejects mismatches. +- **Previous hash.** We reuse the head’s `event_hash` (defaulting to the all-zero hash for genesis events) so writers don’t need to manage hash pointers. + +## 2. Event identifiers + timestamps +- **Event IDs.** If the caller omits `eventId`, we mint a V7 GUID to keep chronological ordering while remaining globally unique. +- **Occurred / recorded timestamps.** `occurredAt` defaults to “now” in UTC (based on `TimeProvider`). `recordedAt` always comes from the service’s `TimeProvider` to avoid caller-provided drift. + +## 3. Status & severity fallbacks +- The reducer already maps event types to canonical statuses. Workflow service only writes `status` when the mutation explicitly changes it (e.g., `accept-risk` → `accepted_risk`, `target-fix` → `in_progress`, `verify-fix` → `verified`, `reopen` → `affected`). Otherwise we leave status null and let the reducer infer it. +- Severity is never inferred inside workflow handlers—they rely on policy evaluation or reducer logic. + +## 4. Attachments metadata +- Attachments now include security context: every entry captures an AES-256-GCM envelope (`algorithm`, `ciphertext`, `nonce`, `tag`, `expiresAt`) derived from `attachments.encryptionKey`. +- Signed URLs are generated with HMAC-SHA256 using `attachments.signedUrlSecret` and inherit `attachments.signedUrlLifetime` (default 15 minutes). URLs plus envelopes share the same expiry window. +- Metadata is normalized (trimmed keys, deterministic ordering) before encryption so ledger hashes remain stable; duplicate IDs are deduplicated. +- Actual binary blobs stay in Evidence Locker/S3; the envelope is what downstream services use to decrypt the blob once downloaded. + +## 5. Validation surface +- All handlers enforce tenant/policy/finding/artifact/vuln IDs, actor identity, and supported actor types. +- Mutation-specific requirements (e.g., assignment requires assignee, accept-risk needs justification) are validated before any ledger append occurs. +- Attachments are validated for ID/file name/MIME type/positive size and 64-char SHA-256 digests before encryption, preventing malformed payloads from burning hashes or emitting invalid URLs. + +These rules let upstream APIs/clients send high-level workflow intents without micromanaging ledger sequencing or hashing, while preserving deterministic ledger entries. LEDGER-29-005 implements the service described here; LEDGER-29-006 builds on it for secure attachment handling. diff --git a/docs/modules/platform/architecture-overview.md b/docs/modules/platform/architecture-overview.md index 82c72b6a6..8ecfc95f6 100644 --- a/docs/modules/platform/architecture-overview.md +++ b/docs/modules/platform/architecture-overview.md @@ -150,6 +150,7 @@ sequenceDiagram 2. `inputbundle.tar.zst` (feeds, policies, tools, environment snapshot). 3. `outputbundle.tar.zst` (SBOM, findings, VEX, logs, Merkle proofs). Every artifact is signed with multi-profile keys (FIPS, GOST, SM, etc.) managed by Authority. See `docs/replay/DETERMINISTIC_REPLAY.md` §2–§5 for the full schema. +- **Reachability subtree:** When reachability recording is enabled, Scanner uploads graphs & runtime traces under `cas://replay//reachability/graphs/` and `cas://replay//reachability/traces/`. Manifest references (StellaOps.Replay.Core) bind these URIs along with analyzer hashes so Replay + Signals can rehydrate explainability evidence deterministically. - **Storage tiers:** Primary storage is Mongo (`replay_runs`, `replay_subjects`) plus the CAS bucket. Evidence Locker mirrors bundles for long-term retention and legal hold workflows (`docs/modules/evidence-locker/architecture.md`). Offline kits package bundles under `offline/replay/` with detached DSSE envelopes for air-gapped verification. - **APIs & ownership:** Scanner WebService produces the bundles via `record` mode, Scanner Worker emits Merkle metadata, Signer/Authority provide DSSE signatures, Attestor anchors manifests to Rekor, CLI/Evidence Locker handle retrieval, and Docs Guild maintains runbooks. Responsibilities are tracked in `docs/implplan/SPRINT_185_replay_core.md` through `SPRINT_187_evidence_cli_replay.md`. - **Operational policies:** Retention defaults to 180 days for hot CAS storage and 2 years for cold Evidence Locker copies. Rotation and pruning follow the checklist in `docs/runbooks/replay_ops.md`. diff --git a/docs/observability/observability.md b/docs/observability/observability.md index 17ca09c4c..0ea95d10f 100644 --- a/docs/observability/observability.md +++ b/docs/observability/observability.md @@ -25,6 +25,51 @@ This guide captures the canonical signals emitted by Concelier and Excititor onc - **Stale ingestion:** Alert when `max_over_time(ingestion_latency_seconds_sum / ingestion_latency_seconds_count)[30m]` exceeds 30 s or if `ingestion_write_total` has no growth for > 60 min. - **Signature drop:** Warn when `rate(ingestion_signature_verified_total{result="fail"}[1h]) > 0`. +### 1.2 · `/obs/excititor/health` + +`GET /obs/excititor/health` (scope `vex.admin`) returns a compact snapshot for Grafana tiles and Console widgets: + +- `ingest` — overall status, worst lag (seconds), and the top connectors (status, lagSeconds, failure count, last success). +- `link` — freshness of consensus/linkset processing plus document counts and the number currently carrying conflicts. +- `signature` — recent coverage window (evaluated, with signatures, verified, failures, unsigned, coverage ratio). +- `conflicts` — rolling totals grouped by status plus per-bucket trend data for charts. + +```json +{ + "generatedAt": "2025-11-08T11:00:00Z", + "ingest": { "status": "healthy", "connectors": [ { "connectorId": "excititor:redhat", "lagSeconds": 45.3 } ] }, + "link": { "status": "warning", "lastConsensusAt": "2025-11-08T10:57:03Z" }, + "signature": { "status": "critical", "documentsEvaluated": 120, "verified": 30, "failures": 2 }, + "conflicts": { "status": "warning", "conflictStatements": 325, "trend": [ { "bucketStart": "2025-11-08T10:00:00Z", "conflicts": 130 } ] } +} +``` + +| Setting | Default | Purpose | +|---------|---------|---------| +| `Excititor:Observability:IngestWarningThreshold` | `06:00:00` | Connector lag before `ingest.status` becomes `warning`. | +| `Excititor:Observability:IngestCriticalThreshold` | `24:00:00` | Connector lag before `ingest.status` becomes `critical`. | +| `Excititor:Observability:LinkWarningThreshold` | `00:15:00` | Maximum acceptable delay between consensus recalculations. | +| `Excititor:Observability:LinkCriticalThreshold` | `01:00:00` | Delay that marks link status as `critical`. | +| `Excititor:Observability:SignatureWindow` | `12:00:00` | Lookback window for signature coverage. | +| `Excititor:Observability:SignatureHealthyCoverage` | `0.8` | Coverage ratio that still counts as healthy. | +| `Excititor:Observability:SignatureWarningCoverage` | `0.5` | Coverage ratio that flips the status to `warning`. | +| `Excititor:Observability:ConflictTrendWindow` | `24:00:00` | Rolling window used for conflict aggregation. | +| `Excititor:Observability:ConflictTrendBucketMinutes` | `60` | Resolution of conflict `trend` buckets. | +| `Excititor:Observability:ConflictWarningRatio` | `0.15` | Fraction of consensus docs with conflicts that triggers `warning`. | +| `Excititor:Observability:ConflictCriticalRatio` | `0.3` | Ratio that marks `conflicts.status` as `critical`. | +| `Excititor:Observability:MaxConnectorDetails` | `50` | Number of connector entries returned (keeps payloads small). | + +### 1.3 · Regression & DI hygiene + +1. **Keep storage/integration tests green when telemetry touches persistence.** + - `./tools/mongodb/local-mongo.sh start` downloads MongoDB 6.0.16 (if needed), launches `rs0`, and prints `export EXCITITOR_TEST_MONGO_URI=mongodb://.../excititor-tests`. Copy that export into your shell. + - `./tools/mongodb/local-mongo.sh restart` is a shortcut for “stop if running, then start” using the same dataset—use it after tweaking config or when tests need a bounce without wiping fixtures. + - `./tools/mongodb/local-mongo.sh clean` stops the instance (if running) and deletes the managed data/log directories so storage tests begin from a pristine catalog. + - Run `dotnet test src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/StellaOps.Excititor.Storage.Mongo.Tests.csproj -nologo -v minimal` (add `--filter` if you only touched specific suites). These tests exercise the same write paths that feed the dashboards, so regressions show up immediately. + - `./tools/mongodb/local-mongo.sh stop` when finished so CI/dev hosts stay clean; `status|logs|shell` are available for troubleshooting. +2. **Declare optional Minimal API dependencies with `[FromServices] ... = null`.** RequestDelegateFactory treats `[FromServices] IVexSigner? signer = null` (or similar) as optional, so host startup succeeds even when tests have not registered that service. This pattern keeps observability endpoints cancellable while avoiding brittle test overrides. + + --- ## 2 · Traces @@ -45,6 +90,35 @@ This guide captures the canonical signals emitted by Concelier and Excititor onc - Use `aoc.guard` spans to inspect guard payload snapshots. Sensitive fields are redacted automatically; raw JSON lives in secure logs only. - For scheduled verification, filter traces by `initiator="scheduled"` to compare runtimes pre/post change. +### 2.3 Telemetry configuration (Excititor) + +- Configure the web service via `Excititor:Telemetry`: + + ```jsonc + { + "Excititor": { + "Telemetry": { + "Enabled": true, + "EnableTracing": true, + "EnableMetrics": true, + "ServiceName": "stellaops-excititor-web", + "OtlpEndpoint": "http://otel-collector:4317", + "OtlpHeaders": { + "Authorization": "Bearer ${OTEL_PUSH_TOKEN}" + }, + "ResourceAttributes": { + "env": "prod-us", + "service.group": "ingestion" + } + } + } + } + ``` + +- Point the OTLP endpoint at the shared collector profile from §1 so Excititor metrics land in the `ingestion_*` dashboards next to Concelier. Resource attributes drive Grafana filtering (e.g., `env`, `service.group`). +- For offline/air-gap bundles set `Enabled=false` and collect the file exporter artifacts from the Offline Kit; import them into Grafana after transfer to keep time-to-truth dashboards consistent. +- Local development templates: run `tools/mongodb/local-mongo.sh start` to spin up a single-node replica set plus the matching `mongosh` client. The script prints the `export EXCITITOR_TEST_MONGO_URI=...` command that integration tests (e.g., `StellaOps.Excititor.Storage.Mongo.Tests`) will honor. Use `restart` for a quick bounce, `clean` to wipe data between suites, and `stop` when finished. + --- ## 3 · Logs @@ -61,6 +135,13 @@ Structured logs include the following keys (JSON): | `violation.code` | Present when guard rejects `ERR_AOC_00x`. | | `verification.window` | Present on `/aoc/verify` job logs. | +Excititor APIs mirror these identifiers via response headers: + +| Header | Purpose | +| --- | --- | +| `X-Stella-TraceId` | W3C trace/span identifier for deep-linking from Console → Grafana/Loki. | +| `X-Stella-CorrelationId` | Stable correlation identifier (respects inbound header or falls back to the request trace ID). | + Logs are shipped to the central Loki/Elasticsearch cluster. Use the template query: ```logql diff --git a/docs/policy/gateway.md b/docs/policy/gateway.md index 03d4ff734..e44466815 100644 --- a/docs/policy/gateway.md +++ b/docs/policy/gateway.md @@ -1,124 +1,138 @@ -# Policy Gateway - -> **Delivery scope:** `StellaOps.Policy.Gateway` minimal API service fronting Policy Engine pack CRUD + activation endpoints for UI/CLI clients. Sender-constrained with DPoP and tenant headers, suitable for online and Offline Kit deployments. - -## 1 · Responsibilities - -- Proxy policy pack CRUD and activation requests to Policy Engine while enforcing scope policies (`policy:read`, `policy:author`, `policy:review`, `policy:operate`, `policy:activate`). -- Normalise responses (DTO + `ProblemDetails`) so Console, CLI, and automation receive consistent payloads. -- Guard activation actions with structured logging and metrics so approvals are auditable. -- Support dual auth modes: - - Forwarded caller tokens (Console/CLI) with DPoP proofs + `X-Stella-Tenant` header. - - Gateway client credentials (DPoP) for service automation or Offline Kit flows when no caller token is present. - -## 2 · Endpoints - -| Route | Method | Description | Required scope(s) | -|-------|--------|-------------|-------------------| -| `/api/policy/packs` | `GET` | List policy packs and revisions for the active tenant. | `policy:read` | -| `/api/policy/packs` | `POST` | Create a policy pack shell or upsert display metadata. | `policy:author` | -| `/api/policy/packs/{packId}/revisions` | `POST` | Create or update a policy revision (draft/approved). | `policy:author` | -| `/api/policy/packs/{packId}/revisions/{version}:activate` | `POST` | Activate a revision, enforcing single/two-person approvals. | `policy:operate`, `policy:activate` | - -### Response shapes - -- Successful responses return camel-case DTOs matching `PolicyPackDto`, `PolicyRevisionDto`, or `PolicyRevisionActivationDto` as described in the Policy Engine API doc (`/docs/api/policy.md`). -- Errors always return RFC 7807 `ProblemDetails` with deterministic fields (`title`, `detail`, `status`). Missing caller credentials now surface `401` with `"Upstream authorization missing"` detail. - -## 3 · Authentication & headers - -| Header | Source | Notes | -|--------|--------|-------| -| `Authorization` | Forwarded caller token *or* gateway client credentials. | Caller tokens must include tenant scope; gateway tokens default to `DPoP` scheme. | -| `DPoP` | Caller or gateway. | Required when Authority mandates proof-of-possession (default). Generated per request; gateway keeps ES256/ES384 key material under `etc/policy-gateway-dpop.pem`. | -| `X-Stella-Tenant` | Caller | Tenant isolation header. Forwarded unchanged; gateway automation omits it. | - -Gateway client credentials are configured in `policy-gateway.yaml`: - -```yaml -policyEngine: - baseAddress: "https://policy-engine.internal" - audience: "api://policy-engine" - clientCredentials: - enabled: true - clientId: "policy-gateway" - clientSecret: "" - scopes: - - policy:read - - policy:author - - policy:review - - policy:operate - - policy:activate - dpop: - enabled: true - keyPath: "../etc/policy-gateway-dpop.pem" - algorithm: "ES256" -``` - -> 🔐 **DPoP key** – store the private key alongside Offline Kit secrets; rotate it whenever the gateway identity or Authority configuration changes. - -## 4 · Metrics & logging - -All activation calls emit: - -- `policy_gateway_activation_requests_total{outcome,source}` – counter labelled with `outcome` (`activated`, `pending_second_approval`, `already_active`, `bad_request`, `not_found`, `unauthorized`, `forbidden`, `error`) and `source` (`caller`, `service`). -- `policy_gateway_activation_latency_ms{outcome,source}` – histogram measuring proxy latency. - -Structured logs (category `StellaOps.Policy.Gateway.Activation`) include `PackId`, `Version`, `Outcome`, `Source`, and upstream status code for audit trails. - -## 5 · Sample `curl` workflows - -Assuming you already obtained a DPoP-bound access token (`$TOKEN`) for tenant `acme`: - -```bash -# Generate a DPoP proof for GET via the CLI helper -DPoP_PROOF=$(stella auth dpop proof \ - --htu https://gateway.example.com/api/policy/packs \ - --htm GET \ - --token "$TOKEN") - -curl -sS https://gateway.example.com/api/policy/packs \ - -H "Authorization: DPoP $TOKEN" \ - -H "DPoP: $DPoP_PROOF" \ - -H "X-Stella-Tenant: acme" - -# Draft a new revision -DPoP_PROOF=$(stella auth dpop proof \ - --htu https://gateway.example.com/api/policy/packs/policy.core/revisions \ - --htm POST \ - --token "$TOKEN") - -curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions \ - -H "Authorization: DPoP $TOKEN" \ - -H "DPoP: $DPoP_PROOF" \ - -H "X-Stella-Tenant: acme" \ - -H "Content-Type: application/json" \ - -d '{"version":5,"requiresTwoPersonApproval":true,"initialStatus":"Draft"}' - -# Activate revision 5 (returns 202 when awaiting the second approver) -DPoP_PROOF=$(stella auth dpop proof \ - --htu https://gateway.example.com/api/policy/packs/policy.core/revisions/5:activate \ - --htm POST \ - --token "$TOKEN") - -curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions/5:activate \ - -H "Authorization: DPoP $TOKEN" \ - -H "DPoP: $DPoP_PROOF" \ - -H "X-Stella-Tenant: acme" \ - -H "Content-Type: application/json" \ - -d '{"comment":"Rollout baseline"}' -``` - -For air-gapped environments, bundle `policy-gateway.yaml` and the DPoP key in the Offline Kit (see `/docs/24_OFFLINE_KIT.md` §5.7). - -> **DPoP proof helper:** Use `stella auth dpop proof` to mint sender-constrained proofs locally. The command accepts `--htu`, `--htm`, and `--token` arguments and emits a ready-to-use header value. Teams maintaining alternate tooling (for example, `scripts/make-dpop.sh`) can substitute it as long as the inputs and output match the CLI behaviour. - -## 6 · Offline Kit guidance - -- Include `policy-gateway.yaml.sample` and the resolved runtime config in the Offline Kit’s `config/` tree. -- Place the DPoP private key under `secrets/policy-gateway-dpop.pem` with restricted permissions; document rotation steps in the manifest. -- When building verification scripts, use the gateway endpoints above instead of hitting Policy Engine directly. The Offline Kit validator now expects `policy_gateway_activation_requests_total` metrics in the Prometheus snapshot. - -## 7 · Change log - -- **2025-10-27 – Sprint 18.5**: Initial gateway bootstrap + activation metrics + DPoP client credentials. +# Policy Gateway + +> **Delivery scope:** `StellaOps.Policy.Gateway` minimal API service fronting Policy Engine pack CRUD + activation endpoints for UI/CLI clients. Sender-constrained with DPoP and tenant headers, suitable for online and Offline Kit deployments. + +## 1 · Responsibilities + +- Proxy policy pack CRUD and activation requests to Policy Engine while enforcing scope policies (`policy:read`, `policy:author`, `policy:review`, `policy:operate`, `policy:activate`). +- Normalise responses (DTO + `ProblemDetails`) so Console, CLI, and automation receive consistent payloads. +- Guard activation actions with structured logging and metrics so approvals are auditable. +- Support dual auth modes: + - Forwarded caller tokens (Console/CLI) with DPoP proofs + `X-Stella-Tenant` header. + - Gateway client credentials (DPoP) for service automation or Offline Kit flows when no caller token is present. + +## 2 · Endpoints + +| Route | Method | Description | Required scope(s) | +|-------|--------|-------------|-------------------| +| `/api/policy/packs` | `GET` | List policy packs and revisions for the active tenant. | `policy:read` | +| `/api/policy/packs` | `POST` | Create a policy pack shell or upsert display metadata. | `policy:author` | +| `/api/policy/packs/{packId}/revisions` | `POST` | Create or update a policy revision (draft/approved). | `policy:author` | +| `/api/policy/packs/{packId}/revisions/{version}:activate` | `POST` | Activate a revision, enforcing single/two-person approvals. | `policy:operate`, `policy:activate` | + +### Response shapes + +- Successful responses return camel-case DTOs matching `PolicyPackDto`, `PolicyRevisionDto`, or `PolicyRevisionActivationDto` as described in the Policy Engine API doc (`/docs/api/policy.md`). +- Errors always return RFC 7807 `ProblemDetails` with deterministic fields (`title`, `detail`, `status`). Missing caller credentials now surface `401` with `"Upstream authorization missing"` detail. + +### Dual-control activation + +- **Config-driven.** Set `PolicyEngine.activation.forceTwoPersonApproval=true` when every activation must collect two distinct `policy:activate` approvals. When false, operators can opt into dual-control per revision (`requiresTwoPersonApproval: true`). +- **Defaults.** `PolicyEngine.activation.defaultRequiresTwoPersonApproval` feeds the default when callers omit the checkbox/flag. +- **Statuses.** First approval on a dual-control revision returns `202 pending_second_approval`; duplicate actors get `400 duplicate_approval`; the second distinct approver receives the usual `200 activated`. +- **Audit trail.** With `PolicyEngine.activation.emitAuditLogs` on, Policy Engine emits structured `policy.activation.*` scopes (pack id, revision, tenant, approver IDs, comments) so the gateway metrics/ELK dashboards can show who approved what. + +#### Activation configuration wiring + +- **Helm ConfigMap.** `deploy/helm/stellaops/values*.yaml` now include a `policy-engine-activation` ConfigMap. The chart automatically injects it via `envFrom` into both the Policy Engine and Policy Gateway pods, so overriding the ConfigMap data updates the services with no manifest edits. +- **Type safety.** Quote ConfigMap values (e.g., `"true"`, `"false"`) because Kubernetes ConfigMaps carry string data. This mirrors the defaults checked into the repo and keeps `helm template` deterministic. +- **File-based overrides (optional).** The Policy Engine host already probes `/config/policy-engine/activation.yaml`, `../etc/policy-engine.activation.yaml`, and ambient `policy-engine.activation.yaml` files beside the binary. Mounting the ConfigMap as a file at `/config/policy-engine/activation.yaml` works immediately if/when we add a volume. +- **Offline/Compose.** Compose/offline bundles can continue exporting `STELLAOPS_POLICY_ENGINE__ACTIVATION__*` variables directly; the ConfigMap wiring simply mirrors those keys for Kubernetes clusters. + +## 3 · Authentication & headers + +| Header | Source | Notes | +|--------|--------|-------| +| `Authorization` | Forwarded caller token *or* gateway client credentials. | Caller tokens must include tenant scope; gateway tokens default to `DPoP` scheme. | +| `DPoP` | Caller or gateway. | Required when Authority mandates proof-of-possession (default). Generated per request; gateway keeps ES256/ES384 key material under `etc/policy-gateway-dpop.pem`. | +| `X-Stella-Tenant` | Caller | Tenant isolation header. Forwarded unchanged; gateway automation omits it. | + +Gateway client credentials are configured in `policy-gateway.yaml`: + +```yaml +policyEngine: + baseAddress: "https://policy-engine.internal" + audience: "api://policy-engine" + clientCredentials: + enabled: true + clientId: "policy-gateway" + clientSecret: "" + scopes: + - policy:read + - policy:author + - policy:review + - policy:operate + - policy:activate + dpop: + enabled: true + keyPath: "../etc/policy-gateway-dpop.pem" + algorithm: "ES256" +``` + +> 🔐 **DPoP key** – store the private key alongside Offline Kit secrets; rotate it whenever the gateway identity or Authority configuration changes. + +## 4 · Metrics & logging + +All activation calls emit: + +- `policy_gateway_activation_requests_total{outcome,source}` – counter labelled with `outcome` (`activated`, `pending_second_approval`, `already_active`, `bad_request`, `not_found`, `unauthorized`, `forbidden`, `error`) and `source` (`caller`, `service`). +- `policy_gateway_activation_latency_ms{outcome,source}` – histogram measuring proxy latency. + +Structured logs (category `StellaOps.Policy.Gateway.Activation`) include `PackId`, `Version`, `Outcome`, `Source`, and upstream status code for audit trails. + +## 5 · Sample `curl` workflows + +Assuming you already obtained a DPoP-bound access token (`$TOKEN`) for tenant `acme`: + +```bash +# Generate a DPoP proof for GET via the CLI helper +DPoP_PROOF=$(stella auth dpop proof \ + --htu https://gateway.example.com/api/policy/packs \ + --htm GET \ + --token "$TOKEN") + +curl -sS https://gateway.example.com/api/policy/packs \ + -H "Authorization: DPoP $TOKEN" \ + -H "DPoP: $DPoP_PROOF" \ + -H "X-Stella-Tenant: acme" + +# Draft a new revision +DPoP_PROOF=$(stella auth dpop proof \ + --htu https://gateway.example.com/api/policy/packs/policy.core/revisions \ + --htm POST \ + --token "$TOKEN") + +curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions \ + -H "Authorization: DPoP $TOKEN" \ + -H "DPoP: $DPoP_PROOF" \ + -H "X-Stella-Tenant: acme" \ + -H "Content-Type: application/json" \ + -d '{"version":5,"requiresTwoPersonApproval":true,"initialStatus":"Draft"}' + +# Activate revision 5 (returns 202 when awaiting the second approver) +DPoP_PROOF=$(stella auth dpop proof \ + --htu https://gateway.example.com/api/policy/packs/policy.core/revisions/5:activate \ + --htm POST \ + --token "$TOKEN") + +curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions/5:activate \ + -H "Authorization: DPoP $TOKEN" \ + -H "DPoP: $DPoP_PROOF" \ + -H "X-Stella-Tenant: acme" \ + -H "Content-Type: application/json" \ + -d '{"comment":"Rollout baseline"}' +``` + +For air-gapped environments, bundle `policy-gateway.yaml` and the DPoP key in the Offline Kit (see `/docs/24_OFFLINE_KIT.md` §5.7). + +> **DPoP proof helper:** Use `stella auth dpop proof` to mint sender-constrained proofs locally. The command accepts `--htu`, `--htm`, and `--token` arguments and emits a ready-to-use header value. Teams maintaining alternate tooling (for example, `scripts/make-dpop.sh`) can substitute it as long as the inputs and output match the CLI behaviour. + +## 6 · Offline Kit guidance + +- Include `policy-gateway.yaml.sample` and the resolved runtime config in the Offline Kit’s `config/` tree. +- Place the DPoP private key under `secrets/policy-gateway-dpop.pem` with restricted permissions; document rotation steps in the manifest. +- When building verification scripts, use the gateway endpoints above instead of hitting Policy Engine directly. The Offline Kit validator now expects `policy_gateway_activation_requests_total` metrics in the Prometheus snapshot. + +## 7 · Change log + +- **2025-10-27 – Sprint 18.5**: Initial gateway bootstrap + activation metrics + DPoP client credentials. diff --git a/docs/replay/DETERMINISTIC_REPLAY.md b/docs/replay/DETERMINISTIC_REPLAY.md index b2538fef6..a5794c55d 100644 --- a/docs/replay/DETERMINISTIC_REPLAY.md +++ b/docs/replay/DETERMINISTIC_REPLAY.md @@ -98,23 +98,58 @@ C --> J[Blob Store: Input/Output Bundles] ], "trustProfile": "sha256:..." }, - "outputs": { - "sbomHash": "sha256:...", - "findingsHash": "sha256:...", - "vexHash": "sha256:...", - "logHash": "sha256:..." - }, - "provenance": { - "signer": "scanner.authority", - "dsseEnvelopeHash": "sha256:...", - "rekorEntry": "optional" - } -} -``` - ---- - -## 4. Deterministic Execution Rules + "outputs": { + "sbomHash": "sha256:...", + "findingsHash": "sha256:...", + "vexHash": "sha256:...", + "logHash": "sha256:..." + }, + "reachability": { + "graphs": [ + { + "kind": "static", + "analyzer": "scanner/java@sha256:...", + "casUri": "cas://replay/scan-123/reachability/static-graph.tar.zst", + "sha256": "abc123" + }, + { + "kind": "framework", + "analyzer": "scanner/framework@sha256:...", + "casUri": "cas://replay/scan-123/reachability/framework-graph.tar.zst", + "sha256": "def456" + } + ], + "runtimeTraces": [ + { + "source": "zastava", + "casUri": "cas://replay/scan-123/reachability/runtime-trace.ndjson.zst", + "sha256": "feedface", + "recordedAt": "2025-11-07T11:10:00Z" + } + ] + }, + "provenance": { + "signer": "scanner.authority", + "dsseEnvelopeHash": "sha256:...", + "rekorEntry": "optional" + } +} +``` + +### 3.2 Reachability Section + +The optional `reachability` block captures the inputs needed to replay explainability decisions: + +| Field | Description | +|-------|-------------| +| `reachability.graphs[]` | References to static/framework callgraph bundles. Each entry records the producing analyzer (`analyzer`/`version`), the CAS URI under `cas://replay//reachability/graphs/`, and the SHA-256 digest of the tarball. | +| `reachability.runtimeTraces[]` | References to runtime observation bundles (e.g., Zastava ND-JSON traces). Each item stores the emitting source, CAS URI (typically `cas://replay//reachability/traces/`), SHA-256, and capture timestamp. | + +Replay engines MUST verify every referenced artifact hash before re-evaluating reachability. Missing graphs downgrade affected signals to `reachability:unknown` and should raise policy warnings. + +--- + +## 4. Deterministic Execution Rules ### 4.1 Environment Normalization diff --git a/docs/replay/DEVS_GUIDE_REPLAY.md b/docs/replay/DEVS_GUIDE_REPLAY.md index a691a2758..5f9b21ce5 100644 --- a/docs/replay/DEVS_GUIDE_REPLAY.md +++ b/docs/replay/DEVS_GUIDE_REPLAY.md @@ -30,10 +30,11 @@ Replay is the foundation for: | **Subject** | OCI image digest, per-layer Merkle roots | ✅ | | **Outputs** | SBOM, Findings, VEX, logs (content hashes) | ✅ | | **Toolchain** | Sbomer, Scanner, Vexer binaries + versions + SHA256 | ✅ | -| **Feeds/VEX sources** | Full or pruned snapshot with Merkle proofs | ✅ | -| **Policy Bundle** | Lattice rules, mutes, trust profiles, thresholds | ✅ | -| **Environment** | OS, arch, locale, TZ, deterministic seed, runtime flags | ✅ | -| **Crypto Profile** | Algorithm suites (FIPS, GOST, SM, eIDAS) | ✅ | +| **Feeds/VEX sources** | Full or pruned snapshot with Merkle proofs | ✅ | +| **Policy Bundle** | Lattice rules, mutes, trust profiles, thresholds | ✅ | +| **Environment** | OS, arch, locale, TZ, deterministic seed, runtime flags | ✅ | +| **Reachability Evidence** | Callgraphs (`graphs[]`), runtime traces (`runtimeTraces[]`), analyzer/version hashes | ✅ | +| **Crypto Profile** | Algorithm suites (FIPS, GOST, SM, eIDAS) | ✅ | --- @@ -69,8 +70,9 @@ stella replay manifest.json --what-if --vary=feeds ## Workflow -1. `stella scan image:tag --record out/` - - Generates Replay Manifest, InputBundle, OutputBundle, DSSE sigs. +1. `stella scan image:tag --record out/` + - Generates Replay Manifest, InputBundle, OutputBundle, DSSE sigs. + - Captures reachability graphs/traces (if enabled) and references them via `reachability.graphs[]` + `runtimeTraces[]`. 2. `stella verify manifest.json` - Validates hashes, signatures, and completeness. 3. `stella replay manifest.json --strict` @@ -82,14 +84,15 @@ stella replay manifest.json --what-if --vary=feeds --- -## Storage - -- **Mongo collections** - - `replay_runs`: manifest + DSSE envelopes + status - - `bundles`: content-addressed (input/output/rootpack) - - `subjects`: OCI digests, Merkle roots per layer -- **File store** - - Bundles stored as `.tar.zst` +## Storage + +- **Mongo collections** + - `replay_runs`: manifest + DSSE envelopes + status + - `bundles`: content-addressed (input/output/rootpack) + - `subjects`: OCI digests, Merkle roots per layer + - `reachability_facts`: graph & runtime trace references tied to scan subjects +- **File store** + - Bundles stored as `.tar.zst` --- diff --git a/docs/replay/TEST_STRATEGY.md b/docs/replay/TEST_STRATEGY.md index 7dc115585..fa590d38e 100644 --- a/docs/replay/TEST_STRATEGY.md +++ b/docs/replay/TEST_STRATEGY.md @@ -19,6 +19,7 @@ This playbook enumerates the deterministic replay validation suite. It guides th | T-RETENTION-006 | **Retention Sweep** | Ensure Evidence Locker prunes hot CAS after SLA while preserving cold storage copies. | Evidence Locker, Ops | Replay retention config, audit logs | | T-OFFLINE-007 | **Offline Kit Replay** | Execute `stella replay` using only Offline Kit artifacts. | CLI, Evidence Locker | Offline kit bundle, local RootPack | | T-OPA-008 | **Runbook Drill** | Simulate replay-driven incident response per `docs/runbooks/replay_ops.md`. | Ops Guild, Scanner, Authority | Runbook checklist, incident notes | +| T-REACH-009 | **Reachability Replay** | Rehydrate reachability graphs/traces from replay bundles and compare against reachbench fixtures. | Scanner, Signals, Replay | `reachbench-2025-expanded`, reachability CAS references | --- @@ -50,7 +51,8 @@ This playbook enumerates the deterministic replay validation suite. It guides th - [ ] Replay verification metrics ingested into Telemetry Stack dashboards. - [ ] Evidence Locker retention job validated against hot/cold tiers. - [ ] CLI documentation updated with troubleshooting steps observed during tests. -- [ ] Runbook drill logged with timestamp and owners in `docs/runbooks/replay_ops.md`. +- [ ] Runbook drill logged with timestamp and owners in `docs/runbooks/replay_ops.md`. +- [ ] Reachability replay drill captured (`T-REACH-009`) with fixture references and Signals verification logs. --- diff --git a/docs/security/crypto-routing-audit-2025-11-07.md b/docs/security/crypto-routing-audit-2025-11-07.md new file mode 100644 index 000000000..75a9fc8ba --- /dev/null +++ b/docs/security/crypto-routing-audit-2025-11-07.md @@ -0,0 +1,113 @@ +# Crypto Routing Audit — 07 Nov 2025 + +**Scope.** Inventory direct uses of `System.Security.Cryptography` (and related primitives) outside the `StellaOps.Cryptography*` stack to identify callers that must be routed through sovereign-aware providers (default, PKCS#11, CryptoPro, future PQC). + +**Method.** `rg -l "using System.Security.Cryptography" src | grep -Ev "__Tests|\.Tests/"` (filtered for runtime code). Counts reflect unique files per top-level module. + +## Summary (runtime files by module) + +| Module/Area | Files bypassing shared crypto | +|-------------|------------------------------| +| Concelier | 34 | +| Scanner | 31 | +| Authority | 20 | +| Excititor | 18 | +| Attestor | 18 | +| EvidenceLocker | 10 | +| Findings / Vuln Explorer | 7 | +| Zastava | 6 | +| ExportCenter| 6 | +| Policy | 4 | +| Scheduler | 3 | +| CLI | 3 | +| Bench | 3 | +| AdvisoryAI | 3 | +| Other (Notify, Registry, Signals, etc.) | 11 combined | + +## Configuring `crypto.regionalProfiles` + +All hosts can now express provider ordering and profile overrides via configuration: + +```yaml +Crypto: + registry: + preferredProviders: + - default + - ru.pkcs11 + activeProfile: ru-offline + profiles: + ru-offline: + preferredProviders: + - ru.cryptopro.csp + - ru.pkcs11 + pkcs11: + keys: + - keyId: ru-slot-token + libraryPath: /usr/local/lib/librutokenecp.so + slotId: "0x1" + privateKeyLabel: signing-key + certificateThumbprint: "" + cryptopro: + keys: + - keyId: ru-csp-token + libraryPath: /opt/cprocsp/lib/libcapi20.so + containerLabel: KRYPTO_PRO_KEY + certificateThumbprint: "" +``` + +Each deployment picks a profile (`activeProfile`) that resolves to a deterministic provider order, and individual services call into `ICryptoProviderRegistry` rather than new-ing crypto stacks directly. + +## Inspecting providers from the CLI + +`stellaops crypto providers` now lists the registered providers, signing algorithms, certificate metadata, and the current preferred order. Use `--json` for machine-readable output or `--profile ` to preview another profile (e.g., `ru-offline`) before flipping configuration. + +## High-priority hotspots + +### Concelier (ingestion + mirror connectors) +- `src/Concelier/StellaOps.Concelier.WebService/Services/OpenApiDiscoveryDocumentProvider.cs` – builds SHA256 hashes for discovery docs inline. +- `src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/Security/MirrorSignatureVerifier.cs` – performs RSA verification directly. +- `src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/RuNkckiConnector.cs` and `.Ru.Bdu` – local hash/signature handling for regional advisories. + +**Action:** Introduce `ICryptoProviderRegistry` consumption inside connector/lib assemblies (probably through lightweight adapter service). File follow-up tasks in `src/Concelier/StellaOps.Concelier.WebService/TASKS.md` and connector TASK boards to migrate hashing/signing to the new PKCS#11/CryptoPro providers (priority for RU feeds to unblock RootPack_RU). + +### Scanner (web service, worker, Sbomer plug-ins) +- `src/Scanner/StellaOps.Scanner.WebService/Utilities/ScanIdGenerator.cs` – direct SHA256 for id derivation. +- `src/Scanner/StellaOps.Scanner.WebService/Services/ReportSigner.cs` – uses `ECDsa.Create()` directly for DSSE hand-off. +- `src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs` – manual digesting before CAS writes. + +**Action:** Create shared `IScanCryptoService` backed by `ICryptoProviderRegistry` so both web service and worker reuse sovereign providers. Add tasks under `src/Scanner/StellaOps.Scanner.WebService/TASKS.md` and `src/Scanner/StellaOps.Scanner.Worker/TASKS.md`. + +### Authority (plugins + signing host) +- `StellaOps.Authority/Signing/*` classes still load PEM/PKCS#12 directly via `X509Certificate2` and `RSA`. +- `AuthoritySecretHasher` and `AuthorityClientCertificateValidator` maintain custom hashing. + +**Action:** Wire Authority signing/loading paths to `ICryptoProviderRegistry` so active keys can point to `ru.cryptopro.csp` or `ru.pkcs11`. Open tasks in `src/Authority/StellaOps.Authority/TASKS.md` covering: signing key loading, JWKS generation, secret hashing migration. + +### Excititor / Attestor +- Excititor connectors (e.g., `src/Excititor/__Libraries/StellaOps.Excititor.Connectors.Ghsa/GhsaConnector.cs`) re-hash payloads in place. +- Attestor submission cache uses SHA256 for bundle ids. + +**Action:** Introduce shared hashing helper that internally calls `ICryptoProviderRegistry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.GostR3410_2012_256)` for digest+sign combos; log follow-ups in respective TASK boards. + +### Evidence Locker / Export Center +- Export packaging code manually builds SHA/V1 digests before signing manifests. + +**Action:** Add backlog tasks for both modules to replace `SHA256.Create()` usage with provider-backed hashing (especially for offline bundle sealing). + +## Next steps +1. **Open remediation tasks per module** referencing this audit (minimum: Concelier, Scanner, Authority, Excititor, Attestor, Evidence Locker, Export Center). Each task should specify which files to migrate and target provider (default vs sovereign). +2. **Provide shared helpers** (e.g., `ICryptoDigestService`, `ICasSigner`) in `StellaOps.Cryptography` to ease adoption and avoid each module talking to the registry manually. +3. **Follow-up audit** once migrations land; rerun the command and ensure only `StellaOps.Cryptography*` and vetted crypto libraries contain direct `System.Security.Cryptography` usage. +4. **RootPack validation runbook** — see `docs/security/rootpack_ru_validation.md` for deterministic tests, hardware validation, and required audit artifacts before shipping RootPack_RU. + +### Remediation tracking snapshot (2025-11-08) + +- **Authority:** `AUTH-CRYPTO-90-001` (Authority TASKS board) +- **Scanner:** `SCANNER-CRYPTO-90-001` (WebService TASKS board) +- **Concelier:** `CONCELIER-CRYPTO-90-001` (WebService TASKS board) +- **Excititor:** `EXCITITOR-CRYPTO-90-001` (WebService TASKS board) +- **Attestor:** `ATTESTOR-CRYPTO-90-001` (Attestor TASKS board) +- **Evidence Locker:** `EVID-CRYPTO-90-001` (Evidence Locker TASKS board) +- **Export Center:** `EXPORT-CRYPTO-90-001` (Exporter Service TASKS board) + +> Stored query artifacts: `/tmp/crypto_runtime_non_tests.txt` (157 runtime files) and aggregated counts above prepared on 2025-11-07. diff --git a/docs/security/dpop-mtls-rollout.md b/docs/security/dpop-mtls-rollout.md new file mode 100644 index 000000000..45f5b5921 --- /dev/null +++ b/docs/security/dpop-mtls-rollout.md @@ -0,0 +1,45 @@ +# Authority DPoP + mTLS Rollout Plan (Sprint 100) + +_Last updated: 2025-11-07_ + +## Objectives +1. Enforce DPoP sender constraints (`AUTH-DPOP-11-001`). +2. Bind high-assurance tenants to mTLS tokens (`AUTH-MTLS-11-002`). +3. Provide telemetry + runbooks so plugins (SEC2/SEC3/SEC5) can validate enforcement without regressions. + +## Phase 1 · Config & Telemetry (ETA 2025-11-08) +- [ ] Extend `authority.yaml` with `security.senderConstraints.dpop` section (nonce store, allowed algorithms, replay window). +- [ ] Wire structured logs (`authority.dpop.request`) containing tenant, client, cnf thumbprint, nonce status. +- [ ] Add `DPoPNonceStore` abstraction + Redis implementation for multi-node deployments. +- [ ] Update integration tests: `AuthorityTokenTests.DPoPNonceRequired`, `AuthorityTokenTests.DPoPMustMatchCnF`. + +## Phase 2 · Enforcement & Fallback (ETA 2025-11-10) +- [ ] Reject `/token` requests lacking DPoP proof when tenant policy requires it. +- [ ] Persist `cnf.jkt` and expose through `/introspect` so downstream services validate sender. +- [ ] Add emergency bypass flag (`security.senderConstraints.dpop.allowTemporaryBypass`) for sealed recap drills; default disabled. + +## Phase 3 · mTLS Binding (ETA 2025-11-10) +- [x] Capture client cert thumbprint on `/token` (mutual TLS) and store in `authority_tokens.senderCertificate`. +- [x] Validate cert hash on `/introspect` and `/fresh-auth`. +- [ ] Document bootstrap/rotation in `docs/11_AUTHORITY.md` + `docs/security/dpop-mtls-rollout.md` (this file). + +## Verification Matrix +| Scenario | Test/Command | Expected | +| --- | --- | --- | +| DPoP required w/out proof | `dotnet test Authority.Tests --filter DPoPRequiresProofTest` | 400 with `use_dpop_nonce` header. | +| Nonce replay | Replay previous proof within window | 401 + audit log entry. | +| mTLS mismatch | Reuse token with different cert | 401 + `senderCertificateMismatch` metric increment. | + +## Telemetry & Alerting +- Metrics: `authority_dpop_nonce_miss_total`, `authority_mtls_mismatch_total` (emitted with `reason` tags for context-missing, missing-certificate, and thumbprint-mismatch cases). +- Logs: `authority.security.senderConstraint` (structured). +- Alerts: Page DevOps when nonce miss > 5% or mTLS mismatches > 0 over 10 min. + +## Dependencies +- Authority Core & Security Guild owners. +- DevOps to provide sealed-mode CI coverage (`DEVOPS-AIRGAP-57-002`). +- Plugin Standard Guild to consume new telemetry once rolled out. + +## Communication +- Daily async update in `#guild-authority` thread referencing this plan. +- Link this document from `docs/implplan/SPRINT_100_identity_signing.md` notes once Phase 1 merges. diff --git a/docs/security/rootpack_ru_package.md b/docs/security/rootpack_ru_package.md new file mode 100644 index 000000000..9c3bff75e --- /dev/null +++ b/docs/security/rootpack_ru_package.md @@ -0,0 +1,68 @@ +# RootPack_RU Packaging Guide + +This guide describes the reproducible process for assembling the sovereign cryptography bundle that backs RootPack_RU deployments. + +## 1. What the bundle contains + +| Directory | Purpose | +|-----------|---------| +| `artifacts/` | Published binaries for `StellaOps.Cryptography.Plugin.CryptoPro` and `StellaOps.Cryptography.Plugin.Pkcs11Gost` (targeting `net10.0`). | +| `config/rootpack_ru.crypto.yaml` | Opinionated configuration template that enables the `ru-offline` crypto profile and defines CryptoPro + PKCS#11 keys. | +| `docs/` | Validation runbook, audit report, and this packaging guide. | +| `trust/` | Russian trust-anchor PEM files copied from `certificates/russian_trusted_*`. | +| `README.txt` | High-level summary plus operator checklist. | + +## 2. Build the bundle + +```bash +# from repository root +scripts/crypto/package-rootpack-ru.sh +# optionally specify destination +scripts/crypto/package-rootpack-ru.sh /tmp/rootpack_ru_$(date -u +%Y%m%dT%H%M%SZ) +``` + +The script performs the following steps: + +1. `dotnet publish` for the CryptoPro + PKCS#11 plug-ins (`Release` configuration). +2. Copies the relevant documentation (`docs/security/rootpack_ru_validation.md`, `docs/security/crypto-routing-audit-2025-11-07.md`, and this guide). +3. Includes the example configuration found at `etc/rootpack/ru/crypto.profile.yaml`. +4. Adds the Russian trust anchors from `certificates/russian_trusted_*`. +5. Emits `README.txt` and optionally creates a `*.tar.gz` archive (set `PACKAGE_TAR=0` to skip the tarball). + +## 3. Attach deterministic test evidence + +After running `scripts/crypto/package-rootpack-ru.sh`, execute the deterministic harness to capture logs: + +```bash +scripts/crypto/run-rootpack-ru-tests.sh +# or specify ROOTPACK_LOG_DIR=/tmp/rootpack_ru_tests scripts/crypto/run-rootpack-ru-tests.sh +``` + +Copy the resulting `logs/rootpack_ru_/` directory into the bundle before distributing it (or store it alongside the tarball in your evidence store). + +Each harness run produces a `README.tests` file plus matching `.log/.trx` pairs for every project. Move the entire directory under an evidence folder inside the bundle (for example `evidence/validation//`) so operators can quickly locate the README, raw logs, and provider JSON snapshots when assembling compliance paperwork: + +```bash +dest="${1:-out/rootpack_ru}" +ts="$(ls logs | grep rootpack_ru_ | sort | tail -n1)" +mkdir -p "${dest}/evidence/validation" +cp -a "logs/${ts}" "${dest}/evidence/validation/${ts}" +``` + +## 4. Hardware validation + audit metadata + +Follow `docs/security/rootpack_ru_validation.md` to: + +- Validate CryptoPro CSP and PKCS#11 tokens. +- Capture `stellaops crypto providers --profile ru-offline --json` output. +- Archive JWKS snapshots and `CryptoProviderMetrics` samples. +- Document hardware serials and operator initials in `hardware_notes.md`. + +Store these artifacts under `logs/rootpack_ru_/` (same directory as the test harness outputs) and reference them in release paperwork. + +## 5. Deployment summary + +1. Import the bundled trust anchors into the target installation (Authority + Scanner). +2. Apply `config/rootpack_ru.crypto.yaml`, update certificate thumbprints, slots, and container labels to match the operator tokens. +3. Restart the services so `ICryptoProviderRegistry` reloads the `ru-offline` profile. +4. Re-run the validation runbook to confirm JWKS, telemetry, and RootPack evidence are aligned with the shipping bundle. diff --git a/docs/security/rootpack_ru_validation.md b/docs/security/rootpack_ru_validation.md new file mode 100644 index 000000000..c8d5ed766 --- /dev/null +++ b/docs/security/rootpack_ru_validation.md @@ -0,0 +1,45 @@ +# RootPack_RU Crypto Validation Runbook + +## Purpose + +This runbook documents the repeatable steps for validating the Russian sovereign crypto profile (CryptoPro + PKCS#11) prior to publishing a RootPack bundle. It supplements the crypto routing audit by covering deterministic tests, hardware validation, and the audit metadata artifacts that must be attached to each release. + +## 1. Deterministic Test Harness + +1. Run `scripts/crypto/run-rootpack-ru-tests.sh` (optional `ROOTPACK_LOG_DIR=/tmp/rootpack_ru_logs` to override the output path). The script executes: + - `src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj` + - `src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/StellaOps.Scanner.Worker.Tests.csproj` + - `src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.csproj` + and emits `.log` + `.trx` pairs plus `README.tests` under `logs/rootpack_ru_/`. +2. For ad-hoc runs (CI or IDE) ensure the same three projects succeed; the cryptography tests validate SHA-256/SHA-512 against BCL implementations and both Streebog variants against BouncyCastle digests. +3. Archive the generated log directory (`logs/rootpack_ru_/`) along with any additional test outputs inside the RootPack evidence bundle. + +## 2. Hardware Validation (CryptoPro CSP) + +1. Install CryptoPro CSP (v5.0 or later) on the validation host and import the qualified certificate configured for the deployment. +2. Configure `StellaOps:Crypto:CryptoPro:Keys` with the container handle and certificate thumbprint and set `StellaOps:Crypto:Registry:ActiveProfile=ru-offline`. +3. Run the provider diagnostics to confirm the key material is visible: + - `stellaops crypto providers --profile ru-offline --json > logs/ru_cryptopro_providers.json` +4. Issue a JWKS fetch (`curl https://authority.local/.well-known/jwks`) and verify the `kid` and `crv` values match the CryptoPro-backed key. +5. Capture the Authority logs showing `AuthoritySecretHasherInitializer` startup and the `CryptoProviderMetrics` counters for `ru.cryptopro.csp` usage. + +## 3. Hardware Validation (PKCS#11 Tokens) + +1. Install the vendor PKCS#11 library (e.g., Rutoken `rtPKCS11ECP.dll` or JaCarta) and configure the slot/PIN inside `StellaOps:Crypto:Pkcs11:Keys`. +2. Switch the registry profile to prioritize `ru.pkcs11` and rerun `stellaops crypto providers --profile ru-offline --json > logs/ru_pkcs11_providers.json`. +3. Execute a signing workflow (Authority JWKS refresh or Scanner manifest publish) and confirm the `CryptoProviderMetrics` counters record `ru.pkcs11` activity. +4. Export the token audit logs (if available) and store them with the RootPack evidence bundle. + +## 4. RootPack Audit Metadata + +Create a metadata bundle per validation run and store it under `logs/rootpack_ru_/` containing: + +- `providers_ru_offline.json` – output from `stellaops crypto providers --profile ru-offline --json`. +- `crypto_tests.txt` – snippets from the unit-test executions listed above. +- `hardware_notes.md` – human-readable notes describing token serials, firmware, and operator initials. +- `jwks_snapshot.json` – raw JWKS response captured after sovereign providers are active. +- `metrics_snapshot.json` – scrape of `CryptoProviderMetrics` Prometheus samples for both providers. + +Attach this directory to the RootPack artifact and reference it from the release checklist. + +Refer back to `docs/security/crypto-routing-audit-2025-11-07.md` for the full inventory of components that must consume the shared cryptography stack, and `docs/security/rootpack_ru_package.md` for packaging/attachment steps. diff --git a/etc/policy-engine.yaml.sample b/etc/policy-engine.yaml.sample index 03fd2e26c..d0fa8e90d 100644 --- a/etc/policy-engine.yaml.sample +++ b/etc/policy-engine.yaml.sample @@ -1,33 +1,38 @@ -# StellaOps Policy Engine configuration template. -# Copy to ../etc/policy-engine.yaml (relative to the Policy Engine content root) -# and adjust values to fit your environment. Environment variables prefixed with -# STELLAOPS_POLICY_ENGINE_ override these values at runtime. - -schemaVersion: 1 - -authority: - enabled: true - issuer: "https://authority.stella-ops.local" - clientId: "policy-engine" - clientSecret: "change-me" - scopes: [ "policy:run", "findings:read", "effective:write" ] - backchannelTimeoutSeconds: 30 - -storage: - connectionString: "mongodb://localhost:27017/policy-engine" - databaseName: "policy_engine" - commandTimeoutSeconds: 30 - -workers: - schedulerIntervalSeconds: 15 - maxConcurrentEvaluations: 4 - -resourceServer: - authority: "https://authority.stella-ops.local" - requireHttpsMetadata: true - audiences: [ "api://policy-engine" ] - requiredScopes: [ "policy:run" ] - requiredTenants: [ ] - bypassNetworks: - - "127.0.0.1/32" - - "::1/128" +# StellaOps Policy Engine configuration template. +# Copy to ../etc/policy-engine.yaml (relative to the Policy Engine content root) +# and adjust values to fit your environment. Environment variables prefixed with +# STELLAOPS_POLICY_ENGINE_ override these values at runtime. + +schemaVersion: 1 + +authority: + enabled: true + issuer: "https://authority.stella-ops.local" + clientId: "policy-engine" + clientSecret: "change-me" + scopes: [ "policy:run", "findings:read", "effective:write" ] + backchannelTimeoutSeconds: 30 + +storage: + connectionString: "mongodb://localhost:27017/policy-engine" + databaseName: "policy_engine" + commandTimeoutSeconds: 30 + +workers: + schedulerIntervalSeconds: 15 + maxConcurrentEvaluations: 4 + +activation: + forceTwoPersonApproval: false + defaultRequiresTwoPersonApproval: false + emitAuditLogs: true + +resourceServer: + authority: "https://authority.stella-ops.local" + requireHttpsMetadata: true + audiences: [ "api://policy-engine" ] + requiredScopes: [ "policy:run" ] + requiredTenants: [ ] + bypassNetworks: + - "127.0.0.1/32" + - "::1/128" diff --git a/etc/rootpack/ru/crypto.profile.yaml b/etc/rootpack/ru/crypto.profile.yaml new file mode 100644 index 000000000..71e6fbb7a --- /dev/null +++ b/etc/rootpack/ru/crypto.profile.yaml @@ -0,0 +1,30 @@ +StellaOps: + Crypto: + Registry: + ActiveProfile: ru-offline + PreferredProviders: + - default + Profiles: + ru-offline: + PreferredProviders: + - ru.cryptopro.csp + - ru.pkcs11 + CryptoPro: + Keys: + - KeyId: ru-csp-default + LibraryPath: /opt/cprocsp/lib/amd64/libcapi20.so + ContainerLabel: CN=RootPack Signing + CertificateThumbprint: "" + Pkcs11: + Keys: + - KeyId: ru-token-default + LibraryPath: /usr/local/lib/librutokenecp.so + SlotId: "0x1" + Pin: "${PKCS11_PIN}" + PrivateKeyLabel: rootpack-signing + CertificateThumbprint: "" + Diagnostics: + Providers: + Enabled: true + Metrics: + LogLevel: Information diff --git a/etc/signals.yaml.sample b/etc/signals.yaml.sample index 867fbaa5e..0fff64b5b 100644 --- a/etc/signals.yaml.sample +++ b/etc/signals.yaml.sample @@ -23,6 +23,7 @@ Signals: Mongo: ConnectionString: "mongodb://localhost:27017/signals" Database: "signals" - CallgraphsCollection: "callgraphs" + CallgraphsCollection: "callgraphs" + ReachabilityFactsCollection: "reachability_facts" Storage: RootPath: "../data/signals-artifacts" diff --git a/ops/deployment/TASKS.md b/ops/deployment/TASKS.md index d48e172bd..f8f8a1697 100644 --- a/ops/deployment/TASKS.md +++ b/ops/deployment/TASKS.md @@ -46,3 +46,5 @@ | HELM-45-001 | TODO | Deployment Guild | COMPOSE-44-001 | Scaffold `deploy/helm/stella` chart with values, component toggles, and pinned image digests for all services; include migration Job templates. | Chart installs in dev cluster; images pinned; lint/tests pass. | | HELM-45-002 | TODO | Deployment Guild, Security Guild | HELM-45-001 | Add TLS/Ingress, NetworkPolicy, PodSecurityContexts, Secrets integration (external secrets), and document security posture. | Helm values support secure defaults; policies validated; docs updated. | | HELM-45-003 | TODO | Deployment Guild, Observability Guild | HELM-45-001 | Implement HPA, PDB, readiness gates, Prometheus scraping annotations, OTel configuration hooks, and upgrade hooks. | Rolling upgrade succeeds in CI; observability wires confirmed; upgrade docs updated. | +| HELM-45-004 | DONE (2025-11-08) | Deployment Guild, Policy Guild | HELM-45-001 | Wire Policy Engine / Gateway pods to consume the `policy-engine-activation` ConfigMap (envFrom/volume mounts), ensure host configuration loads activation overrides, and update Helm/Compose samples accordingly. | Pods mount config map deterministically; activation settings honored in Policy Engine; samples/tests updated for air-gap parity. | +> 2025-11-08: Added config builder support for `/config/policy-engine/activation.yaml`, templated envFrom injection for policy-engine/gateway pods, verified Policy Engine/Gateway tests, and CI now runs `helm lint` + `helm template` for every `values*.yaml`. diff --git a/ops/devops/TASKS.md b/ops/devops/TASKS.md index e079a06e4..57835c9ef 100644 --- a/ops/devops/TASKS.md +++ b/ops/devops/TASKS.md @@ -45,7 +45,10 @@ | DEVOPS-AIRGAP-56-002 | TODO | DevOps Guild, AirGap Importer Guild | AIRGAP-IMP-57-002 | Provide import tooling for bundle staging: checksum validation, offline object-store loader scripts, removable media guidance. | Scripts documented; smoke tests validate import; runbook updated. | | DEVOPS-AIRGAP-56-003 | TODO | DevOps Guild, Container Distribution Guild | EXPORT-AIRGAP-56-002 | Build Bootstrap Pack pipeline bundling images/charts, generating checksums, and publishing manifest for offline transfer. | Pipeline runs in connected env; pack verified in air-gap smoke test; manifest recorded. | | DEVOPS-AIRGAP-57-001 | TODO | DevOps Guild, Mirror Creator Guild | MIRROR-CRT-56-002 | Automate Mirror Bundle creation jobs with dual-control approvals, artifact signing, and checksum publication. | Approval workflow enforced; CI artifact includes DSSE/TUF metadata; audit logs stored. | -| DEVOPS-AIRGAP-57-002 | TODO | DevOps Guild, Authority Guild | AUTH-OBS-50-001 | Configure sealed-mode CI tests that run services with sealed flag and ensure no egress occurs (iptables + mock DNS). | CI suite fails on attempted egress; reports remediation; documentation updated. | +| DEVOPS-AIRGAP-57-002 | DOING (2025-11-08) | DevOps Guild, Authority Guild | AUTH-OBS-50-001 | Configure sealed-mode CI tests that run services with sealed flag and ensure no egress occurs (iptables + mock DNS). | CI suite fails on attempted egress; reports remediation; documentation updated. | +> 2025-11-08: Landed `sealed-mode-compose.yml`, `run-sealed-ci.sh`, and `egress_probe.py`, plus the `.gitea/workflows/build-test-deploy.yml` job that uploads `artifacts/sealed-mode-ci//authority-sealed-ci.json`; waiting on Authority to consume the artefact before flipping DONE. +> 2025-11-07: Blocking AUTH-AIRGAP-57-001 (Authority gating); prioritize sealed-mode CI artifacts so Authority can flip the enforcement switch. +> 2025-11-07: Target ETA agreed with Authority is 2025-11-10 for first CI run (iptables + mock DNS) plus doc updates. | DEVOPS-AIRGAP-58-001 | TODO | DevOps Guild, Notifications Guild | NOTIFY-AIRGAP-56-002 | Provide local SMTP/syslog container templates and health checks for sealed environments; integrate into Bootstrap Pack. | Templates deployed successfully; health checks in CI; docs updated. | | DEVOPS-AIRGAP-58-002 | TODO | DevOps Guild, Observability Guild | DEVOPS-AIRGAP-56-001, DEVOPS-OBS-51-001 | Ship sealed-mode observability stack (Prometheus/Grafana/Tempo/Loki) pre-configured with offline dashboards and no remote exporters. | Stack boots offline; dashboards available; verification script confirms zero egress. | | DEVOPS-REL-17-004 | BLOCKED (2025-10-26) | DevOps Guild | DEVOPS-REL-17-002 | Ensure release workflow publishes `out/release/debug` (build-id tree + manifest) and fails when symbols are missing. | Release job emits debug artefacts, `mirror_debug_store.py` summary committed, warning cleared from build logs, docs updated. | diff --git a/ops/devops/sealed-mode-ci/README.md b/ops/devops/sealed-mode-ci/README.md new file mode 100644 index 000000000..3d786f35f --- /dev/null +++ b/ops/devops/sealed-mode-ci/README.md @@ -0,0 +1,25 @@ +# Sealed-Mode CI Harness + +This harness supports `DEVOPS-AIRGAP-57-002` by exercising services with the `sealed` flag, verifying that no outbound network traffic succeeds, and producing artefacts Authority can use for `AUTH-AIRGAP-57-001` gating. + +## Workflow +1. Run `./run-sealed-ci.sh` from this directory (the script now boots the stack, applies the iptables guard, and captures artefacts automatically). +2. The harness: + - Launches `sealed-mode-compose.yml` with Authority/Signer/Attestor + Mongo. + - Snapshots iptables, injects a `STELLAOPS_SEALED` chain into `DOCKER-USER`/`OUTPUT`, and whitelists only loopback + RFC1918 ranges so container egress is denied. + - Repeatedly polls `/healthz` on `5088/6088/7088` to verify sealed-mode bindings stay healthy while egress is blocked. + - Executes `egress_probe.py`, which runs curl probes from inside the compose network to confirm off-cluster addresses are unreachable. + - Writes logs, iptables counters, and the summary contract to `artifacts/sealed-mode-ci/`. +3. `.gitea/workflows/build-test-deploy.yml` now includes a `sealed-mode-ci` job that runs this script on every push/PR and uploads the artefacts for `AUTH-AIRGAP-57-001`. + +## Outputs +- `authority.health.log`, `signer.health.log`, `attestor.health.log` +- `iptables-docker-user.txt`, `iptables-output.txt` +- `egress-probe.json` +- `compose.log`, `compose.ps` +- `authority-sealed-ci.json` (single file Authority uses to validate the run) + +## TODO +- [ ] Wire into offline kit smoke tests (DEVOPS-AIRGAP-58-001). + +Refer to `docs/security/dpop-mtls-rollout.md` for cross-guild milestones. diff --git a/ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T130258Z/compose.ps b/ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T130258Z/compose.ps new file mode 100644 index 000000000..bddf72aec --- /dev/null +++ b/ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T130258Z/compose.ps @@ -0,0 +1,8 @@ + +The command 'docker' could not be found in this WSL 2 distro. +We recommend to activate the WSL integration in Docker Desktop settings. + +For details about using Docker Desktop with WSL 2, visit: + +https://docs.docker.com/go/wsl2/ + diff --git a/ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T171215Z/compose.ps b/ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T171215Z/compose.ps new file mode 100644 index 000000000..bddf72aec --- /dev/null +++ b/ops/devops/sealed-mode-ci/artifacts/sealed-mode-ci/20251108T171215Z/compose.ps @@ -0,0 +1,8 @@ + +The command 'docker' could not be found in this WSL 2 distro. +We recommend to activate the WSL integration in Docker Desktop settings. + +For details about using Docker Desktop with WSL 2, visit: + +https://docs.docker.com/go/wsl2/ + diff --git a/ops/devops/sealed-mode-ci/authority.harness.yaml b/ops/devops/sealed-mode-ci/authority.harness.yaml new file mode 100644 index 000000000..01524455c --- /dev/null +++ b/ops/devops/sealed-mode-ci/authority.harness.yaml @@ -0,0 +1,54 @@ +schemaVersion: 1 +issuer: http://authority.sealed-ci.local +accessTokenLifetime: 00:02:00 +refreshTokenLifetime: 01:00:00 +identityTokenLifetime: 00:05:00 +authorizationCodeLifetime: 00:05:00 +deviceCodeLifetime: 00:15:00 +pluginDirectories: + - /app +plugins: + configurationDirectory: /app/plugins + descriptors: + standard: + type: standard + assemblyName: StellaOps.Authority.Plugin.Standard + enabled: true + configFile: standard.yaml +storage: + connectionString: mongodb://sealedci:sealedci@mongo:27017/authority?authSource=admin + databaseName: authority + commandTimeout: 00:00:30 +signing: + enabled: true + activeKeyId: sealed-ci + keyPath: /certificates/authority-signing-dev.pem + algorithm: ES256 + keySource: file +bootstrap: + enabled: false +crypto: + providers: [] +security: + senderConstraints: + dpop: + enabled: true + proofLifetime: 00:02:00 + replayWindow: 00:05:00 + nonce: + enabled: false + mtls: + enabled: false +airGap: + egress: + mode: Sealed + allowLoopback: true + allowPrivateNetworks: true + remediationDocumentationUrl: https://docs.stella-ops.org/airgap/sealed-ci + supportContact: airgap-ops@stella-ops.org +tenants: + - name: sealed-ci + roles: + operators: + scopes: + - policy:read diff --git a/ops/devops/sealed-mode-ci/egress_probe.py b/ops/devops/sealed-mode-ci/egress_probe.py new file mode 100644 index 000000000..b0131bcb1 --- /dev/null +++ b/ops/devops/sealed-mode-ci/egress_probe.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Run egress probes from the sealed-mode Docker network.""" +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import time +from datetime import datetime, timezone +from typing import List + +DEFAULT_TARGETS = [ + "https://example.com", + "https://www.cloudflare.com", + "https://releases.stella-ops.org/healthz", +] + + +def run_probe(image: str, network: str, target: str, timeout: int) -> dict: + cmd: List[str] = [ + "docker", + "run", + "--rm", + "--network", + network, + image, + "-fsS", + "--max-time", + str(timeout), + target, + ] + started = time.monotonic() + proc = subprocess.run(cmd, capture_output=True, text=True) + duration = time.monotonic() - started + status = "blocked" if proc.returncode != 0 else "connected" + return { + "target": target, + "status": status, + "durationSeconds": round(duration, 3), + "exitCode": proc.returncode, + "command": " ".join(shlex.quote(part) for part in cmd), + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--network", required=True, help="Docker network to join (compose project network)") + parser.add_argument("--image", default="curlimages/curl:8.6.0", help="Container image providing curl") + parser.add_argument("--timeout", type=int, default=10, help="Curl max-time for each probe (seconds)") + parser.add_argument("--output", required=True, help="Path to write JSON results") + parser.add_argument("targets", nargs="*", help="Override target URLs") + args = parser.parse_args() + + targets = args.targets or DEFAULT_TARGETS + results = [run_probe(args.image, args.network, target, args.timeout) for target in targets] + passed = all(result["status"] == "blocked" for result in results) + payload = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "network": args.network, + "image": args.image, + "targets": results, + "passed": passed, + } + + os.makedirs(os.path.dirname(args.output), exist_ok=True) + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + + return 0 if passed else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # pragma: no cover + print(f"egress probe failed: {exc}", file=sys.stderr) + sys.exit(2) diff --git a/ops/devops/sealed-mode-ci/plugins/standard.yaml b/ops/devops/sealed-mode-ci/plugins/standard.yaml new file mode 100644 index 000000000..5463b5a90 --- /dev/null +++ b/ops/devops/sealed-mode-ci/plugins/standard.yaml @@ -0,0 +1,18 @@ +bootstrapUser: + username: sealed-admin + password: ChangeMe11! +passwordPolicy: + minimumLength: 8 + requireUppercase: false + requireLowercase: true + requireDigit: true + requireSymbol: false +passwordHashing: + algorithm: Argon2id + memorySizeInKib: 8192 + iterations: 2 + parallelism: 1 +lockout: + enabled: false +tokenSigning: + keyDirectory: /certificates diff --git a/ops/devops/sealed-mode-ci/run-sealed-ci.sh b/ops/devops/sealed-mode-ci/run-sealed-ci.sh new file mode 100644 index 000000000..0f88798b2 --- /dev/null +++ b/ops/devops/sealed-mode-ci/run-sealed-ci.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +cd "$SCRIPT_DIR" + +COMPOSE_FILE=${COMPOSE_FILE:-"$SCRIPT_DIR/sealed-mode-compose.yml"} +PROJECT_NAME=${COMPOSE_PROJECT_NAME:-sealedmode} +NETWORK_NAME="${PROJECT_NAME}_sealed-ci" +ARTIFACT_ROOT=${ARTIFACT_ROOT:-"$SCRIPT_DIR/artifacts/sealed-mode-ci"} +STAMP=$(date -u +"%Y%m%dT%H%M%SZ") +OUT_DIR="$ARTIFACT_ROOT/$STAMP" +mkdir -p "$OUT_DIR" + +log() { + printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" +} + +EXIT_CODE=0 +IPTABLES_SNAPSHOT="" + +cleanup() { + local exit_code=$? + log "Collecting docker compose logs" + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs >"$OUT_DIR/compose.log" 2>&1 || true + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps -a >"$OUT_DIR/compose.ps" 2>&1 || true + log "Tearing down sealed-mode stack" + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v >"$OUT_DIR/docker-down.log" 2>&1 || true + if [[ -n "$IPTABLES_SNAPSHOT" && -f "$IPTABLES_SNAPSHOT" ]]; then + log "Restoring iptables snapshot" + sudo iptables-restore <"$IPTABLES_SNAPSHOT" || true + rm -f "$IPTABLES_SNAPSHOT" + fi + log "Artifacts stored at $OUT_DIR" + exit $exit_code +} +trap cleanup EXIT + +log "Pulling compose images (best effort)" +docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" pull --ignore-pull-failures || true + +log "Starting sealed-mode stack" +docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d --remove-orphans + +wait_for_port() { + local port=$1 + local label=$2 + for attempt in $(seq 1 30); do + if curl -fsS --max-time 5 "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then + log "$label responded on port $port" + return 0 + fi + sleep 2 + done + log "$label failed to respond on port $port" + return 1 +} + +wait_for_port 5088 "Authority" || EXIT_CODE=1 +wait_for_port 6088 "Signer" || EXIT_CODE=1 +wait_for_port 7088 "Attestor" || EXIT_CODE=1 + +log "Fetching probe helper image" +docker pull curlimages/curl:8.6.0 >/dev/null 2>&1 || true + +log "Snapshotting iptables state" +IPTABLES_SNAPSHOT=$(mktemp) +sudo iptables-save >"$IPTABLES_SNAPSHOT" + +log "Applying sealed-mode egress policy" +CHAIN="STELLAOPS_SEALED" +sudo iptables -N "$CHAIN" 2>/dev/null || sudo iptables -F "$CHAIN" +for cidr in 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do + sudo iptables -A "$CHAIN" -d "$cidr" -j RETURN +done +sudo iptables -A "$CHAIN" -j LOG --log-prefix "stellaops-sealed-deny " --log-level 4 +sudo iptables -A "$CHAIN" -j DROP +sudo iptables -I DOCKER-USER 1 -j "$CHAIN" +sudo iptables -I OUTPUT 1 -j "$CHAIN" + +check_health() { + local name=$1 + local port=$2 + local url="http://127.0.0.1:${port}/healthz" + local log_file="$OUT_DIR/${name}.health.log" + local status="fail" + for attempt in $(seq 1 20); do + if curl -fsS --max-time 5 "$url" >"$log_file" 2>&1; then + status="pass" + break + fi + sleep 2 + done + if [[ "$status" == "pass" ]]; then + log "$name health check succeeded" + else + log "$name health check failed" + EXIT_CODE=1 + fi + local upper + upper=$(echo "$name" | tr '[:lower:]' '[:upper:]') + eval "${upper}_HEALTH_STATUS=$status" + eval "${upper}_HEALTH_URL=$url" +} + +check_health authority 5088 +check_health signer 6088 +check_health attestor 7088 + +log "Running egress probe via docker network $NETWORK_NAME" +EGRESS_JSON="$OUT_DIR/egress-probe.json" +if python3 "$SCRIPT_DIR/egress_probe.py" --network "$NETWORK_NAME" --image curlimages/curl:8.6.0 --timeout 8 --output "$EGRESS_JSON"; then + EGRESS_STATUS="pass" +else + EGRESS_STATUS="fail" + EXIT_CODE=1 +fi + +log "Dumping iptables counters" +sudo iptables -v -x -L DOCKER-USER >"$OUT_DIR/iptables-docker-user.txt" +sudo iptables -v -x -L OUTPUT >"$OUT_DIR/iptables-output.txt" + +log "Recording summary JSON" +export PROJECT_NAME NETWORK_NAME EGRESS_STATUS EGRESS_JSON +export AUTHORITY_HEALTH_STATUS SIGNER_HEALTH_STATUS ATTESTOR_HEALTH_STATUS +export AUTHORITY_HEALTH_URL SIGNER_HEALTH_URL ATTESTOR_HEALTH_URL +python3 - <<'PY' >"$OUT_DIR/authority-sealed-ci.json" +import json +import os +import sys +from datetime import datetime, timezone + +summary = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "project": os.environ.get("PROJECT_NAME"), + "network": os.environ.get("NETWORK_NAME"), + "health": { + "authority": { + "status": os.environ.get("AUTHORITY_HEALTH_STATUS", "unknown"), + "url": os.environ.get("AUTHORITY_HEALTH_URL"), + "log": "authority.health.log", + }, + "signer": { + "status": os.environ.get("SIGNER_HEALTH_STATUS", "unknown"), + "url": os.environ.get("SIGNER_HEALTH_URL"), + "log": "signer.health.log", + }, + "attestor": { + "status": os.environ.get("ATTESTOR_HEALTH_STATUS", "unknown"), + "url": os.environ.get("ATTESTOR_HEALTH_URL"), + "log": "attestor.health.log", + }, + }, + "egressProbe": { + "status": os.environ.get("EGRESS_STATUS", "unknown"), + "report": os.path.basename(os.environ.get("EGRESS_JSON", "egress-probe.json")), + }, +} +json.dump(summary, sys.stdout, indent=2) +print() +PY + +if [[ $EXIT_CODE -eq 0 ]]; then + log "Sealed-mode CI run completed successfully" +else + log "Sealed-mode CI run completed with failures" +fi + +exit $EXIT_CODE diff --git a/ops/devops/sealed-mode-ci/sealed-mode-compose.yml b/ops/devops/sealed-mode-ci/sealed-mode-compose.yml new file mode 100644 index 000000000..2d5bc32af --- /dev/null +++ b/ops/devops/sealed-mode-ci/sealed-mode-compose.yml @@ -0,0 +1,83 @@ +version: '3.9' + +x-release-labels: &release-labels + com.stellaops.profile: 'sealed-ci' + com.stellaops.airgap.mode: 'sealed' + +networks: + sealed-ci: + driver: bridge + +volumes: + sealed-mongo-data: + +services: + mongo: + image: docker.io/library/mongo@sha256:c258b26dbb7774f97f52aff52231ca5f228273a84329c5f5e451c3739457db49 + command: ['mongod', '--bind_ip_all'] + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: sealedci + MONGO_INITDB_ROOT_PASSWORD: sealedci-secret + volumes: + - sealed-mongo-data:/data/db + networks: + - sealed-ci + labels: *release-labels + + authority: + image: registry.stella-ops.org/stellaops/authority@sha256:a8e8faec44a579aa5714e58be835f25575710430b1ad2ccd1282a018cd9ffcdd + depends_on: + - mongo + restart: unless-stopped + environment: + ASPNETCORE_URLS: http://+:5088 + STELLAOPS_AUTHORITY__ISSUER: http://authority.sealed-ci.local + STELLAOPS_AUTHORITY__MONGO__CONNECTIONSTRING: mongodb://sealedci:sealedci-secret@mongo:27017/authority?authSource=admin + STELLAOPS_AUTHORITY__PLUGINDIRECTORIES__0: /app/plugins + STELLAOPS_AUTHORITY__PLUGINS__CONFIGURATIONDIRECTORY: /app/plugins + STELLAOPS_AUTHORITY__SECURITY__SENDERCONSTRAINTS__DPOP__ENABLED: 'true' + STELLAOPS_AUTHORITY__SECURITY__SENDERCONSTRAINTS__MTLS__ENABLED: 'true' + STELLAOPS_AUTHORITY__AIRGAP__EGRESS__MODE: Sealed + volumes: + - ./authority.harness.yaml:/etc/authority.yaml:ro + - ./plugins:/app/plugins:ro + - ../../../certificates:/certificates:ro + ports: + - '5088:5088' + networks: + - sealed-ci + labels: *release-labels + + signer: + image: registry.stella-ops.org/stellaops/signer@sha256:8bfef9a75783883d49fc18e3566553934e970b00ee090abee9cb110d2d5c3298 + depends_on: + - authority + restart: unless-stopped + environment: + ASPNETCORE_URLS: http://+:6088 + SIGNER__AUTHORITY__BASEURL: http://authority:5088 + SIGNER__POE__INTROSPECTURL: http://authority:5088/device-code + SIGNER__STORAGE__MONGO__CONNECTIONSTRING: mongodb://sealedci:sealedci-secret@mongo:27017/signer?authSource=admin + SIGNER__SEALED__MODE: Enabled + ports: + - '6088:6088' + networks: + - sealed-ci + labels: *release-labels + + attestor: + image: registry.stella-ops.org/stellaops/attestor@sha256:5cc417948c029da01dccf36e4645d961a3f6d8de7e62fe98d845f07cd2282114 + depends_on: + - signer + restart: unless-stopped + environment: + ASPNETCORE_URLS: http://+:7088 + ATTESTOR__SIGNER__BASEURL: http://signer:6088 + ATTESTOR__MONGO__CONNECTIONSTRING: mongodb://sealedci:sealedci-secret@mongo:27017/attestor?authSource=admin + ATTESTOR__SEALED__MODE: Enabled + ports: + - '7088:7088' + networks: + - sealed-ci + labels: *release-labels diff --git a/scripts/crypto/package-rootpack-ru.sh b/scripts/crypto/package-rootpack-ru.sh new file mode 100644 index 000000000..3bfe4f69c --- /dev/null +++ b/scripts/crypto/package-rootpack-ru.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" +OUTPUT_ROOT="${1:-${ROOT_DIR}/build/rootpack_ru_${TIMESTAMP}}" +ARTIFACT_DIR="${OUTPUT_ROOT}/artifacts" +DOC_DIR="${OUTPUT_ROOT}/docs" +CONFIG_DIR="${OUTPUT_ROOT}/config" +TRUST_DIR="${OUTPUT_ROOT}/trust" + +mkdir -p "$ARTIFACT_DIR" "$DOC_DIR" "$CONFIG_DIR" "$TRUST_DIR" + +publish_plugin() { + local project="$1" + local name="$2" + local publish_dir="${ARTIFACT_DIR}/${name}" + echo "[rootpack-ru] Publishing ${project} -> ${publish_dir}" + dotnet publish "$project" -c Release -o "$publish_dir" --nologo >/dev/null +} + +publish_plugin "src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj" "StellaOps.Cryptography.Plugin.CryptoPro" +publish_plugin "src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" "StellaOps.Cryptography.Plugin.Pkcs11Gost" + +cp docs/security/rootpack_ru_validation.md "$DOC_DIR/" +cp docs/security/crypto-routing-audit-2025-11-07.md "$DOC_DIR/" +cp docs/security/rootpack_ru_package.md "$DOC_DIR/" +cp etc/rootpack/ru/crypto.profile.yaml "$CONFIG_DIR/rootpack_ru.crypto.yaml" + +shopt -s nullglob +for pem in "$ROOT_DIR"/certificates/russian_trusted_*; do + cp "$pem" "$TRUST_DIR/" +done +shopt -u nullglob + +cat <"${OUTPUT_ROOT}/README.txt" +RootPack_RU bundle (${TIMESTAMP}) +-------------------------------- +Contents: + - artifacts/ : Sovereign crypto plug-ins published for net10.0 (CryptoPro + PKCS#11) + - config/rootpack_ru.crypto.yaml : example configuration binding registry profiles + - docs/ : validation + audit documentation + - trust/ : Russian trust anchor PEM bundle copied from certificates/ + +Usage: + 1. Review docs/rootpack_ru_package.md for installation steps. + 2. Execute scripts/crypto/run-rootpack-ru-tests.sh (or CI equivalent) and attach the logs to this bundle. + 3. Record hardware validation outputs per docs/rootpack_ru_validation.md and store alongside this directory. +README + +if [[ "${PACKAGE_TAR:-1}" != "0" ]]; then + tarball="${OUTPUT_ROOT}.tar.gz" + echo "[rootpack-ru] Creating ${tarball}" + tar -czf "$tarball" -C "$(dirname "$OUTPUT_ROOT")" "$(basename "$OUTPUT_ROOT")" +fi + +echo "[rootpack-ru] Bundle staged under $OUTPUT_ROOT" diff --git a/scripts/crypto/run-rootpack-ru-tests.sh b/scripts/crypto/run-rootpack-ru-tests.sh new file mode 100644 index 000000000..d897ef930 --- /dev/null +++ b/scripts/crypto/run-rootpack-ru-tests.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +DEFAULT_LOG_ROOT="${ROOT_DIR}/logs/rootpack_ru_$(date -u +%Y%m%dT%H%M%SZ)" +LOG_ROOT="${ROOTPACK_LOG_DIR:-$DEFAULT_LOG_ROOT}" +mkdir -p "$LOG_ROOT" + +PROJECTS=( + "src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj" + "src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/StellaOps.Scanner.Worker.Tests.csproj" + "src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.csproj" +) + +run_test() { + local project="$1" + local safe_name + safe_name="$(basename "${project%.csproj}")" + local log_file="${LOG_ROOT}/${safe_name}.log" + local trx_name="${safe_name}.trx" + + echo "[rootpack-ru] Running tests for ${project}" | tee "$log_file" + dotnet test "$project" \ + --nologo \ + --verbosity minimal \ + --results-directory "$LOG_ROOT" \ + --logger "trx;LogFileName=${trx_name}" | tee -a "$log_file" +} + +PROJECT_SUMMARY=() +for project in "${PROJECTS[@]}"; do + run_test "$project" + safe_name="$(basename "${project%.csproj}")" + PROJECT_SUMMARY+=("$project|$safe_name") + echo "[rootpack-ru] Wrote logs for ${project} -> ${LOG_ROOT}/${safe_name}.log" +done + +{ + echo "RootPack_RU deterministic test harness" + echo "Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "Log Directory: $LOG_ROOT" + echo "" + echo "Projects:" + for entry in "${PROJECT_SUMMARY[@]}"; do + project_path="${entry%%|*}" + safe_name="${entry##*|}" + printf ' - %s (log: %s.log, trx: %s.trx)\n' "$project_path" "$safe_name" "$safe_name" + done +} > "$LOG_ROOT/README.tests" + +echo "Logs and TRX files available under $LOG_ROOT" diff --git a/src/Aoc/__Libraries/StellaOps.Aoc/AocGuardOptions.cs b/src/Aoc/__Libraries/StellaOps.Aoc/AocGuardOptions.cs index d6185f72a..591d0d276 100644 --- a/src/Aoc/__Libraries/StellaOps.Aoc/AocGuardOptions.cs +++ b/src/Aoc/__Libraries/StellaOps.Aoc/AocGuardOptions.cs @@ -24,7 +24,9 @@ public sealed record AocGuardOptions "createdAt", "created_at", "ingestedAt", - "ingested_at" + "ingested_at", + "links", + "advisory_key" }, StringComparer.OrdinalIgnoreCase) .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/Aoc/__Tests/StellaOps.Aoc.Tests/AocWriteGuardTests.cs b/src/Aoc/__Tests/StellaOps.Aoc.Tests/AocWriteGuardTests.cs index ba473f6cf..560e35408 100644 --- a/src/Aoc/__Tests/StellaOps.Aoc.Tests/AocWriteGuardTests.cs +++ b/src/Aoc/__Tests/StellaOps.Aoc.Tests/AocWriteGuardTests.cs @@ -27,12 +27,42 @@ public sealed class AocWriteGuardTests } """); - var result = Guard.Validate(document.RootElement); - - Assert.True(result.IsValid); - Assert.Empty(result.Violations); - } - + var result = Guard.Validate(document.RootElement); + + Assert.True(result.IsValid); + Assert.Empty(result.Violations); + } + + [Fact] + public void Validate_AllowsLinksAndAdvisoryKey_ByDefault() + { + using var document = JsonDocument.Parse(""" + { + "tenant": "default", + "source": {"vendor": "osv"}, + "upstream": { + "upstream_id": "GHSA-xxxx", + "content_hash": "sha256:abc", + "signature": { "present": false } + }, + "content": { + "format": "OSV", + "raw": {"id": "GHSA-xxxx"} + }, + "linkset": {}, + "links": [ + { "scheme": "cve", "value": "CVE-2025-0001" } + ], + "advisory_key": "ghsa-xxxx" + } + """); + + var result = Guard.Validate(document.RootElement); + + Assert.True(result.IsValid); + Assert.Empty(result.Violations); + } + [Fact] public void Validate_FlagsMissingTenant() { diff --git a/src/Attestor/StellaOps.Attestor/TASKS.md b/src/Attestor/StellaOps.Attestor/TASKS.md index 13f512d2d..77d78b44b 100644 --- a/src/Attestor/StellaOps.Attestor/TASKS.md +++ b/src/Attestor/StellaOps.Attestor/TASKS.md @@ -5,6 +5,7 @@ > Remark (2025-10-19): Wave 0 prerequisites reviewed (none outstanding); ATTESTOR-API-11-201, ATTESTOR-VERIFY-11-202, and ATTESTOR-OBS-11-203 tracked as DOING per Wave 0A kickoff. > Remark (2025-10-19): Dual-log submissions, signature/proof verification, and observability hardening landed; attestor endpoints now rate-limited per client with correlation-ID logging and updated docs/tests. +| ATTESTOR-CRYPTO-90-001 | TODO | Attestor Service Guild, Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Migrate bundle hashing, witness proof caching, and signing submissions to `ICryptoProviderRegistry`/`ICryptoHash` so RootPack_RU deployments use CryptoPro or PKCS#11 per `docs/security/crypto-routing-audit-2025-11-07.md`. | Attestor services resolve registry providers; DSSE signing/verifying honors config profiles; tests cover default + `ru-offline` modes; docs updated. | --- diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md index b90fdef70..d50041e2a 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md @@ -2,9 +2,9 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| -| SEC2.PLG | BLOCKED (2025-10-21) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`.
⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 to stabilise Authority auth surfaces before final verification + publish. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. | -| SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after).
⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 so limiter telemetry contract matches final authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. | -| SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog.
⛔ Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. | +| SEC2.PLG | BLOCKED (2025-10-21) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`.
⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 to stabilise Authority auth surfaces (PLUGIN-DI-08-001 landed 2025-10-21). | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. | +| SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after).
⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 is done, limiter telemetry just awaits the updated Authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. | +| SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog.
⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002; scoped DI work is complete. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. | | PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles.
⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. | | PLG7.RFC | DONE (2025-11-03) | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. | | PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. | diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthoritySecretHasher.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthoritySecretHasher.cs index 82068db90..866bf2b44 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthoritySecretHasher.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/AuthoritySecretHasher.cs @@ -1,5 +1,8 @@ +using System; using System.Security.Cryptography; using System.Text; +using System.Threading; +using StellaOps.Cryptography; namespace StellaOps.Authority.Plugins.Abstractions; @@ -8,18 +11,55 @@ namespace StellaOps.Authority.Plugins.Abstractions; /// public static class AuthoritySecretHasher { + private static ICryptoHash? configuredHash; + private static string defaultAlgorithm = HashAlgorithms.Sha256; + /// - /// Computes a stable SHA-256 hash for the provided secret. + /// Configures the shared crypto hash service used for secret hashing. /// - public static string ComputeHash(string secret) + public static void Configure(ICryptoHash hash, string? algorithmId = null) + { + ArgumentNullException.ThrowIfNull(hash); + Volatile.Write(ref configuredHash, hash); + if (!string.IsNullOrWhiteSpace(algorithmId)) + { + defaultAlgorithm = NormalizeAlgorithm(algorithmId); + } + } + + /// + /// Computes a stable hash for the provided secret using the configured crypto provider. + /// + public static string ComputeHash(string secret, string? algorithmId = null) { if (string.IsNullOrEmpty(secret)) { return string.Empty; } + var algorithm = string.IsNullOrWhiteSpace(algorithmId) + ? defaultAlgorithm + : NormalizeAlgorithm(algorithmId); + + var hasher = Volatile.Read(ref configuredHash); + if (hasher is not null) + { + var digest = hasher.ComputeHash(Encoding.UTF8.GetBytes(secret), algorithm); + return Convert.ToBase64String(digest); + } + + if (!string.Equals(algorithm, HashAlgorithms.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Authority secret hasher is not configured for the requested algorithm."); + } + using var sha256 = SHA256.Create(); var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(secret)); return Convert.ToBase64String(bytes); } + + private static string NormalizeAlgorithm(string algorithmId) + => string.IsNullOrWhiteSpace(algorithmId) + ? HashAlgorithms.Sha256 + : algorithmId.Trim().ToUpperInvariant(); } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityTokenDocument.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityTokenDocument.cs index 715d04e3e..a95a90eb3 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityTokenDocument.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityTokenDocument.cs @@ -74,6 +74,10 @@ public sealed class AuthorityTokenDocument [BsonIgnoreIfNull] public string? SenderKeyThumbprint { get; set; } + [BsonElement("senderCertificateHex")] + [BsonIgnoreIfNull] + public string? SenderCertificateHex { get; set; } + [BsonElement("senderNonce")] [BsonIgnoreIfNull] public string? SenderNonce { get; set; } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs index 91b01b9f5..a52849f18 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Console/ConsoleEndpointsTests.cs @@ -1,10 +1,12 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Linq; -using Microsoft.AspNetCore.Authentication; +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Linq; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -155,8 +157,8 @@ public sealed class ConsoleEndpointsTests } [Fact] - public async Task TokenIntrospect_FlagsInactive_WhenExpired() - { + public async Task TokenIntrospect_FlagsInactive_WhenExpired() + { var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z")); var sink = new RecordingAuthEventSink(); await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty(), Array.Empty())); @@ -189,8 +191,118 @@ public sealed class ConsoleEndpointsTests var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.token.introspect"); Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome); - Assert.Equal(2, events.Count); - } + Assert.Equal(2, events.Count); + } + + [Fact] + public async Task VulnerabilityFindings_ReturnsSamplePayload() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z")); + var sink = new RecordingAuthEventSink(); + await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty(), Array.Empty())); + + var accessor = app.Services.GetRequiredService(); + accessor.Principal = CreatePrincipal( + tenant: "tenant-default", + scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead }, + expiresAt: timeProvider.GetUtcNow().AddMinutes(30)); + + var client = app.CreateTestClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var response = await client.GetAsync("/console/vuln/findings?severity=high"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var items = json.RootElement.GetProperty("items"); + Assert.True(items.GetArrayLength() >= 1); + Assert.Equal("CVE-2024-12345", items[0].GetProperty("coordinates").GetProperty("advisoryId").GetString()); + } + + [Fact] + public async Task VulnerabilityFindingDetail_ReturnsExpandedDocument() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z")); + var sink = new RecordingAuthEventSink(); + await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty(), Array.Empty())); + + var accessor = app.Services.GetRequiredService(); + accessor.Principal = CreatePrincipal( + tenant: "tenant-default", + scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead }, + expiresAt: timeProvider.GetUtcNow().AddMinutes(30)); + + var client = app.CreateTestClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var response = await client.GetAsync("/console/vuln/tenant-default:advisory-ai:sha256:5d1a"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var summary = json.RootElement.GetProperty("summary"); + Assert.Equal("tenant-default:advisory-ai:sha256:5d1a", summary.GetProperty("findingId").GetString()); + Assert.Equal("reachable", summary.GetProperty("reachability").GetProperty("status").GetString()); + var detailReachability = json.RootElement.GetProperty("reachability"); + Assert.Equal("reachable", detailReachability.GetProperty("status").GetString()); + } + + [Fact] + public async Task VulnerabilityTicket_ReturnsDeterministicPayload() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z")); + var sink = new RecordingAuthEventSink(); + await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty(), Array.Empty())); + + var accessor = app.Services.GetRequiredService(); + accessor.Principal = CreatePrincipal( + tenant: "tenant-default", + scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead }, + expiresAt: timeProvider.GetUtcNow().AddMinutes(30)); + + var client = app.CreateTestClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var payload = new ConsoleVulnerabilityTicketRequest( + Selection: new[] { "tenant-default:advisory-ai:sha256:5d1a" }, + TargetSystem: "servicenow", + Metadata: new Dictionary { ["assignmentGroup"] = "runtime-security" }); + + var response = await client.PostAsJsonAsync("/console/vuln/tickets", payload); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.StartsWith("console-ticket::tenant-default::", json.RootElement.GetProperty("ticketId").GetString()); + Assert.Equal("servicenow", payload.TargetSystem); + } + + [Fact] + public async Task VexStatements_ReturnsSampleRows() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z")); + var sink = new RecordingAuthEventSink(); + await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty(), Array.Empty())); + + var accessor = app.Services.GetRequiredService(); + accessor.Principal = CreatePrincipal( + tenant: "tenant-default", + scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.VexRead }, + expiresAt: timeProvider.GetUtcNow().AddMinutes(30)); + + var client = app.CreateTestClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme); + client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default"); + + var response = await client.GetAsync("/console/vex/statements?advisoryId=CVE-2024-12345"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var items = json.RootElement.GetProperty("items"); + Assert.True(items.GetArrayLength() >= 1); + Assert.Equal("CVE-2024-12345", items[0].GetProperty("advisoryId").GetString()); + } private static ClaimsPrincipal CreatePrincipal( string tenant, @@ -259,9 +371,10 @@ public sealed class ConsoleEndpointsTests builder.Services.AddSingleton(timeProvider); builder.Services.AddSingleton(sink); builder.Services.AddSingleton(new FakeTenantCatalog(tenants)); - builder.Services.AddSingleton(); - builder.Services.AddHttpContextAccessor(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var authBuilder = builder.Services.AddAuthentication(options => { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs index b5e59a1f3..f420e965a 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs @@ -1,2904 +1,3162 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text.Json; -using System.Linq; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; -using Microsoft.IdentityModel.Tokens; -using StellaOps.Configuration; -using OpenIddict.Abstractions; -using StellaOps.Authority.Security; -using StellaOps.Auth.Security.Dpop; -using OpenIddict.Extensions; -using OpenIddict.Server; -using OpenIddict.Server.AspNetCore; -using StellaOps.Auth.Abstractions; -using StellaOps.Authority.OpenIddict; -using StellaOps.Authority.OpenIddict.Handlers; -using StellaOps.Authority.Plugins.Abstractions; -using StellaOps.Authority.Storage.Mongo.Documents; -using StellaOps.Authority.Storage.Mongo.Sessions; -using StellaOps.Authority.Storage.Mongo.Stores; -using StellaOps.Authority.RateLimiting; -using StellaOps.Cryptography.Audit; -using Xunit; -using MongoDB.Bson; -using MongoDB.Driver; -using static StellaOps.Authority.Tests.OpenIddict.TestHelpers; - -namespace StellaOps.Authority.Tests.OpenIddict; - -public class ClientCredentialsHandlersTests -{ - private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); - - [Fact] - public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'jobs:write' is not allowed for this client.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsObsIncidentScope() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "obs:incident"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "obs:incident"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Contains("obs:incident", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_Allows_WhenConfigurationMatches() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read jobs:trigger"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.ClientTenantProperty)); - Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]); - - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "jobs:read" }, grantedScopes); - Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_Allows_NewIngestionScopes() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:ingest advisory:read", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "advisory:ingest" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsServiceAccountWhenAuthorized() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 5; - }); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-observer", - Tenant = "tenant-alpha", - AllowedScopes = new List { "jobs:read" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var tokenStore = new TestTokenStore(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - Assert.True(context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountObj)); - var resolvedAccount = Assert.IsType(serviceAccountObj); - Assert.Equal("svc-observer", resolvedAccount.AccountId); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Contains("jobs:read", grantedScopes); - Assert.Equal("svc-observer", metadataAccessor.GetMetadata()?.SubjectId); - Assert.Equal(AuthorityTokenKinds.ServiceAccount, context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsWhenServiceAccountQuotaExceeded() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 1; - }); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-observer", - Tenant = "tenant-alpha", - AllowedScopes = new List { "jobs:read" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "existing-token", - Status = "valid", - Tenant = "tenant-alpha", - ClientId = clientDocument.ClientId, - ServiceAccountId = "svc-observer", - TokenKind = AuthorityTokenKinds.ServiceAccount, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), - Scope = new List { "jobs:read" } - } - }; - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsVulnScopeWhenAttributeAmbiguous() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - serviceAccount.Attributes["env"] = new List { "prod", "stage" }; - serviceAccount.Attributes["owner"] = new List { "security" }; - serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - new TestServiceAccountStore(serviceAccount), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("vuln_env must be supplied when multiple values are configured for the service account.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsVulnScopeWhenAttributesProvided() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - serviceAccount.Attributes["env"] = new List { "prod", "stage" }; - serviceAccount.Attributes["owner"] = new List { "security" }; - serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; - - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - Assert.Equal("prod", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty]); - Assert.Equal("security", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty]); - Assert.Equal("tier-1", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty]); - - var metadata = metadataAccessor.GetMetadata(); - Assert.NotNull(metadata); - Assert.True(metadata!.Tags.TryGetValue("authority.vuln_env", out var envTag)); - Assert.Equal("prod", envTag); - } - - [Fact] - public async Task HandleClientCredentials_PersistsVulnAttributes() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var options = TestHelpers.CreateAuthorityOptions(); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - serviceAccount.Attributes["env"] = new List { "prod", "stage" }; - serviceAccount.Attributes["owner"] = new List { "security" }; - serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; - - var tokenStore = new TestTokenStore(); - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - transaction.Options = new OpenIddictServerOptions - { - AccessTokenLifetime = TimeSpan.FromMinutes(5) - }; - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var sessionAccessor = new NullMongoSessionAccessor(); - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - - Assert.True(handleContext.IsRequestHandled); - var principal = Assert.IsType(handleContext.Principal); - Assert.Equal("prod", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityEnvironment)); - Assert.Equal("security", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityOwner)); - Assert.Equal("tier-1", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityBusinessTier)); - - var persistHandler = new PersistTokensHandler( - tokenStore, - sessionAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = principal, - AccessTokenPrincipal = principal - }; - - await persistHandler.HandleAsync(signInContext); - - Assert.NotNull(tokenStore.Inserted); - Assert.Equal("prod", tokenStore.Inserted!.VulnerabilityEnvironment); - Assert.Equal("security", tokenStore.Inserted!.VulnerabilityOwner); - Assert.Equal("tier-1", tokenStore.Inserted!.VulnerabilityBusinessTier); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:read aoc:verify", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsSignalsScopeWithoutAocVerify() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "signals:read signals:write signals:admin aoc:verify", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "signals:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Theory] - [InlineData(StellaOpsScopes.AirgapSeal)] - [InlineData(StellaOpsScopes.AirgapImport)] - [InlineData(StellaOpsScopes.AirgapStatusRead)] - public async Task ValidateClientCredentials_RejectsAirgapScopesWithoutTenant(string scope) - { - var clientId = $"airgap-{scope.Replace(':', '-')}-client"; - var clientDocument = CreateClient( - clientId: clientId, - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: scope); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Air-gap scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(scope, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Theory] - [InlineData(StellaOpsScopes.AirgapSeal)] - [InlineData(StellaOpsScopes.AirgapImport)] - [InlineData(StellaOpsScopes.AirgapStatusRead)] - public async Task ValidateClientCredentials_AllowsAirgapScopesWithTenant(string scope) - { - var clientId = $"airgap-{scope.Replace(':', '-')}-client"; - var clientDocument = CreateClient( - clientId: clientId, - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: scope, - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { scope }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPolicyAuthorWithoutTenant() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:author"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsPolicyAuthorWithTenant() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:author", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "policy:author" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPolicyPublishForClientCredentials() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:publish", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:publish"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'policy:publish' requires interactive authentication.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPolicyPromoteForClientCredentials() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "policy:promote", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:promote"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); - Assert.Equal("Scope 'policy:promote' requires interactive authentication.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PolicyPromote, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:read aoc:verify", - tenant: "tenant-alpha"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsAocVerifyWithoutTenant() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "aoc:verify"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "aoc:verify"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Scope 'aoc:verify' requires a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing() - { - var clientDocument = CreateClient( - clientId: "policy-engine", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "effective:write findings:read policy:run", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - Assert.True(clientDocument.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant)); - Assert.Equal("tenant-default", clientDocument.Properties[AuthorityClientMetadataKeys.Tenant]); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); - Assert.Equal("Scope 'effective:write' is reserved for the Policy Engine service identity.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "policy-engine", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "effective:write findings:read policy:run"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Policy Engine service identity requires a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsEffectiveWrite_ForPolicyEngineServiceIdentity() - { - var clientDocument = CreateClient( - clientId: "policy-engine", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "effective:write findings:read policy:run", - tenant: "tenant-default"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "effective:write" }, grantedScopes); - - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchOperate_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Operator actions require 'operator_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTicketMissing() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Operator actions require 'operator_ticket'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill actions require 'backfill_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill actions require 'backfill_ticket'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var longReason = new string('a', 257); - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, longReason); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill reason must not exceed 256 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var longTicket = new string('b', 129); - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, longTicket); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Backfill ticket must not exceed 128 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_PopulatesBackfillMetadata_OnSuccess() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:backfill orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); - SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:backfill" }, grantedScopes); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillReasonProperty]); - Assert.Equal("Backfill drift repair", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillTicketProperty]); - Assert.Equal("INC-9981", ticket); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchOperate_WithReasonAndTicket() - { - var clientDocument = CreateClient( - clientId: "orch-operator", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read orch:operate", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); - SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:operate" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); - Assert.Equal("resume source after maintenance", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); - Assert.Equal("INC-2045", ticket); - Assert.Equal("resume source after maintenance", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); - Assert.Equal("INC-2045", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota orch:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "raise export center quota for tenant"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, "CHG-7721"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Quota changes require 'quota_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var longReason = new string('a', 257); - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, longReason); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Quota reason must not exceed 256 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTicketTooLong() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "increase concurrency to unblock digests"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, new string('b', 129)); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Quota ticket must not exceed 128 characters.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonOnly() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "grant five extra concurrent backfills"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:quota" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); - Assert.Equal("grant five extra concurrent backfills", reason); - Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.QuotaTicketProperty)); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonAndTicket() - { - var clientDocument = CreateClient( - clientId: "orch-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:quota", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "temporary burst for export audit"); - SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, "RFC-5541"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:quota" }, grantedScopes); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); - Assert.Equal("temporary burst for export audit", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaTicketProperty]); - Assert.Equal("RFC-5541", ticket); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsExportViewer_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "export-viewer", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.viewer"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Export scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.ExportViewer, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsExportViewer_WithTenant() - { - var clientDocument = CreateClient( - clientId: "export-viewer", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.viewer", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "export.viewer" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsPacksRun_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "task-runner", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "packs.run packs.read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - string? violationTag = null; - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == TestActivitySource.Name, - Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => - { - violationTag ??= activity.GetTagItem("authority.pack_scope_violation") as string; - } - }; - ActivitySource.AddActivityListener(listener); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Pack scopes require a tenant assignment.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PacksRun, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - Assert.Equal(StellaOpsScopes.PacksRun, violationTag); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsPacksRun_WithTenant() - { - var clientDocument = CreateClient( - clientId: "task-runner", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "packs.run packs.read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "packs.run" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsExportAdmin_WhenReasonMissing() - { - var clientDocument = CreateClient( - clientId: "export-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.admin", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Export admin actions require 'export_reason'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsExportAdmin_WhenTicketMissing() - { - var clientDocument = CreateClient( - clientId: "export-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.admin", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Export admin actions require 'export_ticket'.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsExportAdmin_WithReasonAndTicket() - { - var clientDocument = CreateClient( - clientId: "export-admin", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "export.admin", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - TestHelpers.CreateAuthorityOptions(), - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); - SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); - SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "export.admin" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty]); - Assert.Equal("Rotate encryption keys after incident postmortem", reason); - var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty]); - Assert.Equal("INC-9001", ticket); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing() - { - var clientDocument = CreateClient( - clientId: "cartographer-service", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:write graph:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); - Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMismatch() - { - var clientDocument = CreateClient( - clientId: "cartographer-service", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:write graph:read", - tenant: "tenant-default"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); - Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsGraphScopes_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "graph-api", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:read graph:export"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Graph scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsGraphRead_WithTenant() - { - var clientDocument = CreateClient( - clientId: "graph-api", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:read graph:export", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "graph:read" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsOrchRead_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "orch-dashboard", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsOrchRead_WithTenant() - { - var clientDocument = CreateClient( - clientId: "orch-dashboard", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "orch:read", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "orch:read" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "concelier-ingestor", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:ingest advisory:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Advisory scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsVexScopes_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "excitor-ingestor", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vex:ingest vex:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vex:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsVulnViewScope_WhenTenantMissing() - { - var clientDocument = CreateClient( - clientId: "vuln-explorer-ui", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view vuln:investigate"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("vuln_env is required when requesting vulnerability scopes.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant() - { - var clientDocument = CreateClient( - clientId: "concelier-ingestor", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "advisory:ingest advisory:read aoc:verify", - tenant: "tenant-default"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity() - { - var clientDocument = CreateClient( - clientId: "cartographer-service", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "graph:write graph:read", - tenant: "tenant-default"); - clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.Cartographer; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); - var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); - Assert.Equal(new[] { "graph:write" }, grantedScopes); - var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); - Assert.Equal("tenant-default", tenant); - } - - [Fact] - public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var sink = new TestAuthEventSink(); - var options = TestHelpers.CreateAuthorityOptions(); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - sink, - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - SetParameter(transaction,"unexpected_param", "value"); - - await handler.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); - - var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper"); - Assert.Contains(tamperEvent.Properties, property => - string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) && - string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public async Task ValidateDpopProof_AllowsSenderConstrainedClient() - { - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Dpop.Enabled = true; - opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; - }); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; - - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var securityKey = new ECDsaSecurityKey(ecdsa) - { - KeyId = Guid.NewGuid().ToString("N") - }; - var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); - var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint()); - - var clientStore = new TestClientStore(clientDocument); - var auditSink = new TestAuthEventSink(); - var rateMetadata = new TestRateLimiterMetadataAccessor(); - - var dpopValidator = new DpopProofValidator( - Options.Create(new DpopValidationOptions()), - new InMemoryDpopReplayCache(TimeProvider.System), - TimeProvider.System, - NullLogger.Instance); - - var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); - - var dpopHandler = new ValidateDpopProofHandler( - options, - clientStore, - dpopValidator, - nonceStore, - rateMetadata, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Options = new OpenIddictServerOptions(); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - httpContext.Request.Scheme = "https"; - httpContext.Request.Host = new HostString("authority.test"); - httpContext.Request.Path = "/token"; - - var now = TimeProvider.System.GetUtcNow(); - var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); - httpContext.Request.Headers["DPoP"] = proof; - - transaction.Properties[typeof(HttpContext).FullName!] = httpContext; - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await dpopHandler.HandleAsync(validateContext); - - Assert.False(validateContext.IsRejected); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var tokenStore = new TestTokenStore(); - var serviceAccountStore = new TestServiceAccountStore(); - var validateHandler = new ValidateClientCredentialsHandler( - clientStore, - registry, - TestActivitySource, - auditSink, - rateMetadata, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var sessionAccessor = new NullMongoSessionAccessor(); - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - rateMetadata, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - Assert.True(handleContext.IsRequestHandled); - - var persistHandler = new PersistTokensHandler( - tokenStore, - sessionAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = handleContext.Principal, - AccessTokenPrincipal = handleContext.Principal - }; - - await persistHandler.HandleAsync(signInContext); - - var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); - Assert.False(string.IsNullOrWhiteSpace(confirmationClaim)); - - using (var confirmationJson = JsonDocument.Parse(confirmationClaim!)) - { - Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString()); - } - - Assert.NotNull(tokenStore.Inserted); - Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint); - Assert.Equal(expectedThumbprint, tokenStore.Inserted!.SenderKeyThumbprint); - } - - [Fact] - public async Task ValidateDpopProof_IssuesNonceChallenge_WhenNonceMissing() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Dpop.Enabled = true; - options.Security.SenderConstraints.Dpop.Nonce.Enabled = true; - options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear(); - options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer"); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - Assert.Contains("signer", options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - allowedAudiences: "signer"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; - - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var securityKey = new ECDsaSecurityKey(ecdsa) - { - KeyId = Guid.NewGuid().ToString("N") - }; - - var clientStore = new TestClientStore(clientDocument); - var auditSink = new TestAuthEventSink(); - var rateMetadata = new TestRateLimiterMetadataAccessor(); - - var dpopValidator = new DpopProofValidator( - Options.Create(new DpopValidationOptions()), - new InMemoryDpopReplayCache(TimeProvider.System), - TimeProvider.System, - NullLogger.Instance); - - var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); - - var dpopHandler = new ValidateDpopProofHandler( - options, - clientStore, - dpopValidator, - nonceStore, - rateMetadata, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Options = new OpenIddictServerOptions(); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - httpContext.Request.Scheme = "https"; - httpContext.Request.Host = new HostString("authority.test"); - httpContext.Request.Path = "/token"; - - var now = TimeProvider.System.GetUtcNow(); - var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); - httpContext.Request.Headers["DPoP"] = proof; - - transaction.Properties[typeof(HttpContext).FullName!] = httpContext; - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await dpopHandler.HandleAsync(validateContext); - - Assert.True(validateContext.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error); - var authenticateHeader = Assert.Single( - httpContext.Response.Headers, - header => string.Equals(header.Key, "WWW-Authenticate", StringComparison.OrdinalIgnoreCase)).Value; - Assert.Contains("use_dpop_nonce", authenticateHeader.ToString()); - Assert.True(httpContext.Response.Headers.TryGetValue("DPoP-Nonce", out var nonceValues)); - Assert.False(StringValues.IsNullOrEmpty(nonceValues)); - Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge"); - } - - [Fact] - public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; - - using var rsa = RSA.Create(2048); - var certificateRequest = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - using var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); - var hexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = hexThumbprint - }); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var auditSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(); - var tokenStore = new TestTokenStore(); - var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; - httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - auditSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - validator, - httpContextAccessor, - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected, context.ErrorDescription ?? context.Error); - Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]); - - var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256)); - Assert.Equal(expectedBase64, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]); - } - - [Fact] - public async Task ValidateClientCredentials_RejectsMtlsClient_WhenCertificateMissing() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - validator, - httpContextAccessor, - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - } - - [Fact] - public async Task ValidateClientCredentials_Rejects_WhenAudienceRequiresMtlsButClientConfiguredForDpop() - { - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Mtls.Enabled = true; - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); - }); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - allowedAudiences: "signer"); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("Requested audiences require mutual TLS sender constraint.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateClientCredentials_RequiresMtlsWhenAudienceMatchesEnforcement() - { - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Mtls.Enabled = true; - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); - opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); - }); - - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - allowedAudiences: "signer"); - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = "DEADBEEF" - }); - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var certificateValidator = new RecordingCertificateValidator(); - var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; - - var handler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - new TestAuthEventSink(), - new TestRateLimiterMetadataAccessor(), - new TestServiceAccountStore(), - new TestTokenStore(), - TimeProvider.System, - certificateValidator, - httpContextAccessor, - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("client_certificate_required", context.ErrorDescription); - Assert.True(certificateValidator.Invoked); - } - - [Fact] - public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims() - { - var clientDocument = CreateClient( - secret: null, - clientType: "public", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:trigger", - allowedAudiences: "signer", - tenant: "Tenant-Alpha"); - - var descriptor = CreateDescriptor(clientDocument); - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); - var tokenStore = new TestTokenStore(); - var sessionAccessor = new NullMongoSessionAccessor(); - var authSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(); - var options = TestHelpers.CreateAuthorityOptions(); - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - authSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger"); - transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.Instance); - - var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRequestHandled); - Assert.NotNull(context.Principal); - Assert.Contains("signer", context.Principal!.GetAudiences()); - - Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success); - - var identityProviderClaim = context.Principal?.GetClaim(StellaOpsClaimTypes.IdentityProvider); - Assert.Equal(clientDocument.Plugin, identityProviderClaim); - - var principal = context.Principal ?? throw new InvalidOperationException("Principal missing"); - Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); - var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); - Assert.False(string.IsNullOrWhiteSpace(tokenId)); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = principal, - AccessTokenPrincipal = principal - }; - - await persistHandler.HandleAsync(signInContext); - - var persisted = Assert.IsType(tokenStore.Inserted); - Assert.Equal(tokenId, persisted.TokenId); - Assert.Equal(clientDocument.ClientId, persisted.ClientId); - Assert.Equal("valid", persisted.Status); - Assert.Equal("tenant-alpha", persisted.Tenant); - Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope); - } - - [Fact] - public async Task HandleClientCredentials_PersistsServiceAccountMetadata() - { - var clientDocument = CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:read", - tenant: "tenant-alpha"); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-ops", - Tenant = "tenant-alpha", - AllowedScopes = new List { "jobs:read" }, - AuthorizedClients = new List { clientDocument.ClientId } - }; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var tokenStore = new TestTokenStore(); - var sessionAccessor = new NullMongoSessionAccessor(); - var authSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 5; - }); - - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - authSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); - transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(10); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops"); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - Assert.True(handleContext.IsRequestHandled); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = handleContext.Principal, - AccessTokenPrincipal = handleContext.Principal - }; - - await persistHandler.HandleAsync(signInContext); - - var inserted = tokenStore.Inserted; - Assert.NotNull(inserted); - Assert.Equal("svc-ops", inserted!.ServiceAccountId); - Assert.Equal("service_account", inserted.TokenKind); - Assert.NotNull(inserted.ActorChain); - Assert.Contains(clientDocument.ClientId, inserted.ActorChain!); - Assert.Equal("tenant-alpha", inserted.Tenant); - Assert.Contains("jobs:read", inserted.Scope); +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using StellaOps.Configuration; +using OpenIddict.Abstractions; +using StellaOps.Authority.Security; +using StellaOps.Auth.Security.Dpop; +using OpenIddict.Extensions; +using OpenIddict.Server; +using OpenIddict.Server.AspNetCore; +using StellaOps.Auth.Abstractions; +using StellaOps.Authority.OpenIddict; +using StellaOps.Authority.OpenIddict.Handlers; +using StellaOps.Authority.Plugins.Abstractions; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.RateLimiting; +using StellaOps.Cryptography.Audit; +using Xunit; +using MongoDB.Bson; +using MongoDB.Driver; +using static StellaOps.Authority.Tests.OpenIddict.TestHelpers; + +namespace StellaOps.Authority.Tests.OpenIddict; + +public class ClientCredentialsHandlersTests +{ + + [Fact] + public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'jobs:write' is not allowed for this client.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsObsIncidentScope() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "obs:incident"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "obs:incident"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Contains("obs:incident", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_Allows_WhenConfigurationMatches() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read jobs:trigger"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.ClientTenantProperty)); + Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]); + + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "jobs:read" }, grantedScopes); + Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_Allows_NewIngestionScopes() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:ingest advisory:read", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "advisory:ingest" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsServiceAccountWhenAuthorized() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 5; + }); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-observer", + Tenant = "tenant-alpha", + AllowedScopes = new List { "jobs:read" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var tokenStore = new TestTokenStore(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + Assert.True(context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountObj)); + var resolvedAccount = Assert.IsType(serviceAccountObj); + Assert.Equal("svc-observer", resolvedAccount.AccountId); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Contains("jobs:read", grantedScopes); + Assert.Equal("svc-observer", metadataAccessor.GetMetadata()?.SubjectId); + Assert.Equal(AuthorityTokenKinds.ServiceAccount, context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsWhenServiceAccountQuotaExceeded() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 1; + }); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-observer", + Tenant = "tenant-alpha", + AllowedScopes = new List { "jobs:read" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "existing-token", + Status = "valid", + Tenant = "tenant-alpha", + ClientId = clientDocument.ClientId, + ServiceAccountId = "svc-observer", + TokenKind = AuthorityTokenKinds.ServiceAccount, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), + Scope = new List { "jobs:read" } + } + }; + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer"); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsVulnScopeWhenAttributeAmbiguous() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + serviceAccount.Attributes["env"] = new List { "prod", "stage" }; + serviceAccount.Attributes["owner"] = new List { "security" }; + serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + metadataAccessor, + new TestServiceAccountStore(serviceAccount), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("vuln_env must be supplied when multiple values are configured for the service account.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsVulnScopeWhenAttributesProvided() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + serviceAccount.Attributes["env"] = new List { "prod", "stage" }; + serviceAccount.Attributes["owner"] = new List { "security" }; + serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; + + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Equal("prod", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty]); + Assert.Equal("security", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty]); + Assert.Equal("tier-1", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty]); + + var metadata = metadataAccessor.GetMetadata(); + Assert.NotNull(metadata); + Assert.True(metadata!.Tags.TryGetValue("authority.vuln_env", out var envTag)); + Assert.Equal("prod", envTag); + } + + [Fact] + public async Task HandleClientCredentials_PersistsVulnAttributes() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var options = TestHelpers.CreateAuthorityOptions(); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + serviceAccount.Attributes["env"] = new List { "prod", "stage" }; + serviceAccount.Attributes["owner"] = new List { "security" }; + serviceAccount.Attributes["business_tier"] = new List { "tier-1" }; + + var tokenStore = new TestTokenStore(); + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + transaction.Options = new OpenIddictServerOptions + { + AccessTokenLifetime = TimeSpan.FromMinutes(5) + }; + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "security"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var sessionAccessor = new NullMongoSessionAccessor(); + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + + Assert.True(handleContext.IsRequestHandled); + var principal = Assert.IsType(handleContext.Principal); + Assert.Equal("prod", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityEnvironment)); + Assert.Equal("security", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityOwner)); + Assert.Equal("tier-1", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityBusinessTier)); + + var persistHandler = new PersistTokensHandler( + tokenStore, + sessionAccessor, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = principal, + AccessTokenPrincipal = principal + }; + + await persistHandler.HandleAsync(signInContext); + + Assert.NotNull(tokenStore.Inserted); + Assert.Equal("prod", tokenStore.Inserted!.VulnerabilityEnvironment); + Assert.Equal("security", tokenStore.Inserted!.VulnerabilityOwner); + Assert.Equal("tier-1", tokenStore.Inserted!.VulnerabilityBusinessTier); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:read aoc:verify", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsSignalsScopeWithoutAocVerify() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "signals:read signals:write signals:admin aoc:verify", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "signals:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Theory] + [InlineData(StellaOpsScopes.AirgapSeal)] + [InlineData(StellaOpsScopes.AirgapImport)] + [InlineData(StellaOpsScopes.AirgapStatusRead)] + public async Task ValidateClientCredentials_RejectsAirgapScopesWithoutTenant(string scope) + { + var clientId = $"airgap-{scope.Replace(':', '-')}-client"; + var clientDocument = CreateClient( + clientId: clientId, + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: scope); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Air-gap scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(scope, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Theory] + [InlineData(StellaOpsScopes.AirgapSeal)] + [InlineData(StellaOpsScopes.AirgapImport)] + [InlineData(StellaOpsScopes.AirgapStatusRead)] + public async Task ValidateClientCredentials_AllowsAirgapScopesWithTenant(string scope) + { + var clientId = $"airgap-{scope.Replace(':', '-')}-client"; + var clientDocument = CreateClient( + clientId: clientId, + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: scope, + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: scope); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { scope }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPolicyAuthorWithoutTenant() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:author"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsPolicyAuthorWithTenant() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:author", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "policy:author" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPolicyPublishForClientCredentials() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:publish", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:publish"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'policy:publish' requires interactive authentication.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPolicyPromoteForClientCredentials() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "policy:promote", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:promote"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error); + Assert.Equal("Scope 'policy:promote' requires interactive authentication.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PolicyPromote, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:read aoc:verify", + tenant: "tenant-alpha"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsAocVerifyWithoutTenant() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "aoc:verify"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "aoc:verify"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Scope 'aoc:verify' requires a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing() + { + var clientDocument = CreateClient( + clientId: "policy-engine", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "effective:write findings:read policy:run", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + Assert.True(clientDocument.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant)); + Assert.Equal("tenant-default", clientDocument.Properties[AuthorityClientMetadataKeys.Tenant]); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); + Assert.Equal("Scope 'effective:write' is reserved for the Policy Engine service identity.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "policy-engine", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "effective:write findings:read policy:run"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Policy Engine service identity requires a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsEffectiveWrite_ForPolicyEngineServiceIdentity() + { + var clientDocument = CreateClient( + clientId: "policy-engine", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "effective:write findings:read policy:run", + tenant: "tenant-default"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "effective:write" }, grantedScopes); + + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchOperate_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Operator actions require 'operator_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTicketMissing() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Operator actions require 'operator_ticket'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill actions require 'backfill_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill actions require 'backfill_ticket'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenReasonTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var longReason = new string('a', 257); + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, longReason); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill reason must not exceed 256 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchBackfill_WhenTicketTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var longTicket = new string('b', 129); + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, longTicket); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Backfill ticket must not exceed 128 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_PopulatesBackfillMetadata_OnSuccess() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:backfill orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:backfill"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillReasonParameterName, "Backfill drift repair"); + SetParameter(transaction, AuthorityOpenIddictConstants.BackfillTicketParameterName, "INC-9981"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:backfill" }, grantedScopes); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillReasonProperty]); + Assert.Equal("Backfill drift repair", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.BackfillTicketProperty]); + Assert.Equal("INC-9981", ticket); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchOperate_WithReasonAndTicket() + { + var clientDocument = CreateClient( + clientId: "orch-operator", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read orch:operate", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance"); + SetParameter(transaction, AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:operate" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); + Assert.Equal("resume source after maintenance", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); + Assert.Equal("INC-2045", ticket); + Assert.Equal("resume source after maintenance", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]); + Assert.Equal("INC-2045", context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota orch:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "raise export center quota for tenant"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, "CHG-7721"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Quota changes require 'quota_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenReasonTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var longReason = new string('a', 257); + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, longReason); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Quota reason must not exceed 256 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchQuota_WhenTicketTooLong() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "increase concurrency to unblock digests"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, new string('b', 129)); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Quota ticket must not exceed 128 characters.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonOnly() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "grant five extra concurrent backfills"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:quota" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); + Assert.Equal("grant five extra concurrent backfills", reason); + Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.QuotaTicketProperty)); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchQuota_WithReasonAndTicket() + { + var clientDocument = CreateClient( + clientId: "orch-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:quota", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:quota"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaReasonParameterName, "temporary burst for export audit"); + SetParameter(transaction, AuthorityOpenIddictConstants.QuotaTicketParameterName, "RFC-5541"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:quota" }, grantedScopes); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaReasonProperty]); + Assert.Equal("temporary burst for export audit", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.QuotaTicketProperty]); + Assert.Equal("RFC-5541", ticket); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsExportViewer_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "export-viewer", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.viewer"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Export scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.ExportViewer, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsExportViewer_WithTenant() + { + var clientDocument = CreateClient( + clientId: "export-viewer", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.viewer", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "export.viewer" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsPacksRun_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "task-runner", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "packs.run packs.read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + string? violationTag = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == TestInstruments.ActivitySource.Name, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + violationTag ??= activity.GetTagItem("authority.pack_scope_violation") as string; + } + }; + ActivitySource.AddActivityListener(listener); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Pack scopes require a tenant assignment.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PacksRun, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + Assert.Equal(StellaOpsScopes.PacksRun, violationTag); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsPacksRun_WithTenant() + { + var clientDocument = CreateClient( + clientId: "task-runner", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "packs.run packs.read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "packs.run"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "packs.run" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsExportAdmin_WhenReasonMissing() + { + var clientDocument = CreateClient( + clientId: "export-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.admin", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Export admin actions require 'export_reason'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsExportAdmin_WhenTicketMissing() + { + var clientDocument = CreateClient( + clientId: "export-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.admin", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Export admin actions require 'export_ticket'.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsExportAdmin_WithReasonAndTicket() + { + var clientDocument = CreateClient( + clientId: "export-admin", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "export.admin", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + TestHelpers.CreateAuthorityOptions(), + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem"); + SetParameter(transaction, AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "export.admin" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + var reason = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty]); + Assert.Equal("Rotate encryption keys after incident postmortem", reason); + var ticket = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty]); + Assert.Equal("INC-9001", ticket); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing() + { + var clientDocument = CreateClient( + clientId: "cartographer-service", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:write graph:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); + Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMismatch() + { + var clientDocument = CreateClient( + clientId: "cartographer-service", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:write graph:read", + tenant: "tenant-default"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); + Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsGraphScopes_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "graph-api", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:read graph:export"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Graph scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsGraphRead_WithTenant() + { + var clientDocument = CreateClient( + clientId: "graph-api", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:read graph:export", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "graph:read" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsOrchRead_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "orch-dashboard", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsOrchRead_WithTenant() + { + var clientDocument = CreateClient( + clientId: "orch-dashboard", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "orch:read", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "orch:read" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "concelier-ingestor", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:ingest advisory:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Advisory scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsVexScopes_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "excitor-ingestor", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vex:ingest vex:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vex:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsVulnViewScope_WhenTenantMissing() + { + var clientDocument = CreateClient( + clientId: "vuln-explorer-ui", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view vuln:investigate"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("vuln_env is required when requesting vulnerability scopes.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant() + { + var clientDocument = CreateClient( + clientId: "concelier-ingestor", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "advisory:ingest advisory:read aoc:verify", + tenant: "tenant-default"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity() + { + var clientDocument = CreateClient( + clientId: "cartographer-service", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "graph:write graph:read", + tenant: "tenant-default"); + clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.Cartographer; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); + var grantedScopes = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); + Assert.Equal(new[] { "graph:write" }, grantedScopes); + var tenant = Assert.IsType(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); + Assert.Equal("tenant-default", tenant); + } + + [Fact] + public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var sink = new TestAuthEventSink(); + var options = TestHelpers.CreateAuthorityOptions(); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + sink, + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + SetParameter(transaction,"unexpected_param", "value"); + + await handler.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); + + var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper"); + Assert.Contains(tamperEvent.Properties, property => + string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) && + string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ValidateDpopProof_AllowsSenderConstrainedClient() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Dpop.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var securityKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Guid.NewGuid().ToString("N") + }; + var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); + var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint()); + + var clientStore = new TestClientStore(clientDocument); + var auditSink = new TestAuthEventSink(); + var rateMetadata = new TestRateLimiterMetadataAccessor(); + + var dpopValidator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var dpopHandler = new ValidateDpopProofHandler( + options, + clientStore, + dpopValidator, + nonceStore, + rateMetadata, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + + var now = TimeProvider.System.GetUtcNow(); + var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); + httpContext.Request.Headers["DPoP"] = proof; + + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await dpopHandler.HandleAsync(validateContext); + + Assert.False(validateContext.IsRejected); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var tokenStore = new TestTokenStore(); + var serviceAccountStore = new TestServiceAccountStore(); + var validateHandler = new ValidateClientCredentialsHandler( + clientStore, + registry, + TestInstruments.ActivitySource, + auditSink, + rateMetadata, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var sessionAccessor = new NullMongoSessionAccessor(); + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + rateMetadata, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + Assert.True(handleContext.IsRequestHandled); + + var persistHandler = new PersistTokensHandler( + tokenStore, + sessionAccessor, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = handleContext.Principal, + AccessTokenPrincipal = handleContext.Principal + }; + + await persistHandler.HandleAsync(signInContext); + + var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); + Assert.False(string.IsNullOrWhiteSpace(confirmationClaim)); + + using (var confirmationJson = JsonDocument.Parse(confirmationClaim!)) + { + Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString()); + } + + Assert.NotNull(tokenStore.Inserted); + Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint); + Assert.Equal(expectedThumbprint, tokenStore.Inserted!.SenderKeyThumbprint); + } + + [Fact] + public async Task ValidateDpopProof_RequiresSenderConstraint_WhenAudienceMatchesConfiguration() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Dpop.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear(); + opts.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer"); + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + + var clientStore = new TestClientStore(clientDocument); + var auditSink = new TestAuthEventSink(); + var rateMetadata = new TestRateLimiterMetadataAccessor(); + var dpopValidator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var dpopHandler = new ValidateDpopProofHandler( + options, + clientStore, + dpopValidator, + nonceStore, + rateMetadata, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Response.Body = new MemoryStream(); + + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await dpopHandler.HandleAsync(validateContext); + + Assert.True(validateContext.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error); + Assert.True(httpContext.Response.Headers.TryGetValue("DPoP-Nonce", out var nonceValues)); + Assert.False(StringValues.IsNullOrEmpty(nonceValues)); + Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge"); + } + + [Fact] + public async Task PersistTokensHandler_PopulatesSenderMetadata_ForDpopNonce() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Dpop.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear(); + opts.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer"); + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var securityKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Guid.NewGuid().ToString("N") + }; + var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); + var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint()); + + var clientStore = new TestClientStore(clientDocument); + var auditSink = new TestAuthEventSink(); + var rateMetadata = new TestRateLimiterMetadataAccessor(); + var dpopValidator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var issuedNonce = await nonceStore.IssueAsync( + "signer", + clientDocument.ClientId, + expectedThumbprint, + options.Security.SenderConstraints.Dpop.Nonce.Ttl, + options.Security.SenderConstraints.Dpop.Nonce.MaxIssuancePerMinute); + Assert.Equal(DpopNonceIssueStatus.Success, issuedNonce.Status); + + var dpopHandler = new ValidateDpopProofHandler( + options, + clientStore, + dpopValidator, + nonceStore, + rateMetadata, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Response.Body = new MemoryStream(); + + var now = TimeProvider.System.GetUtcNow(); + var proof = TestHelpers.CreateDpopProof( + securityKey, + httpContext.Request.Method, + httpContext.Request.GetDisplayUrl(), + now.ToUnixTimeSeconds(), + nonce: issuedNonce.Nonce); + httpContext.Request.Headers["DPoP"] = proof; + + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await dpopHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var tokenStore = new TestTokenStore(); + var serviceAccountStore = new TestServiceAccountStore(); + var validateHandler = new ValidateClientCredentialsHandler( + clientStore, + registry, + TestInstruments.ActivitySource, + auditSink, + rateMetadata, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + new NullMongoSessionAccessor(), + rateMetadata, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + var persistHandler = new PersistTokensHandler( + tokenStore, + new NullMongoSessionAccessor(), + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var validationContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validateHandler.HandleAsync(validationContext); + Assert.False(validationContext.IsRejected); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction) + { + Principal = validationContext.Principal + }; + await handleHandler.HandleAsync(handleContext); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = handleContext.Principal, + AccessTokenPrincipal = handleContext.Principal + }; + + await persistHandler.HandleAsync(signInContext); + + var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); + Assert.False(string.IsNullOrWhiteSpace(confirmationClaim)); + + using (var confirmationJson = JsonDocument.Parse(confirmationClaim!)) + { + Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString()); + } + + Assert.NotNull(tokenStore.Inserted); + Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint); + Assert.Equal(expectedThumbprint, tokenStore.Inserted.SenderKeyThumbprint); + Assert.Equal(issuedNonce.Nonce, tokenStore.Inserted.SenderNonce); + } + + [Fact] + public async Task PersistTokensHandler_PopulatesSenderCertificateHex_ForMtls() + { + var options = TestHelpers.CreateAuthorityOptions(); + var tokenStore = new TestTokenStore(); + var persistHandler = new PersistTokensHandler( + tokenStore, + new NullMongoSessionAccessor(), + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + const string expectedBase64Thumbprint = "base64-thumbprint"; + const string expectedHexThumbprint = "ABCDEF1234"; + + transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = AuthoritySenderConstraintKinds.Mtls; + transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty] = expectedBase64Thumbprint; + transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty] = expectedHexThumbprint; + + var principal = CreatePrincipal("mtls-client", "token-mtls", "standard"); + principal.SetScopes("jobs:read"); + principal.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, AuthoritySenderConstraintKinds.Mtls); + principal.SetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, expectedHexThumbprint); + + var confirmation = JsonSerializer.Serialize(new Dictionary + { + ["x5t#S256"] = expectedBase64Thumbprint + }); + principal.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = principal, + AccessTokenPrincipal = principal + }; + + await persistHandler.HandleAsync(signInContext); + + Assert.NotNull(tokenStore.Inserted); + Assert.Equal(AuthoritySenderConstraintKinds.Mtls, tokenStore.Inserted!.SenderConstraint); + Assert.Equal(expectedBase64Thumbprint, tokenStore.Inserted.SenderKeyThumbprint); + Assert.Equal(expectedHexThumbprint, tokenStore.Inserted.SenderCertificateHex); + } + + [Fact] + public async Task ValidateDpopProof_IssuesNonceChallenge_WhenNonceMissing() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Dpop.Enabled = true; + options.Security.SenderConstraints.Dpop.Nonce.Enabled = true; + options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear(); + options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer"); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + Assert.Contains("signer", options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var securityKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Guid.NewGuid().ToString("N") + }; + + var clientStore = new TestClientStore(clientDocument); + var auditSink = new TestAuthEventSink(); + var rateMetadata = new TestRateLimiterMetadataAccessor(); + + var dpopValidator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var dpopHandler = new ValidateDpopProofHandler( + options, + clientStore, + dpopValidator, + nonceStore, + rateMetadata, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + + var now = TimeProvider.System.GetUtcNow(); + var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds()); + httpContext.Request.Headers["DPoP"] = proof; + + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await dpopHandler.HandleAsync(validateContext); + + Assert.True(validateContext.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error); + var authenticateHeader = Assert.Single( + httpContext.Response.Headers, + header => string.Equals(header.Key, "WWW-Authenticate", StringComparison.OrdinalIgnoreCase)).Value; + Assert.Contains("use_dpop_nonce", authenticateHeader.ToString()); + Assert.True(httpContext.Response.Headers.TryGetValue("DPoP-Nonce", out var nonceValues)); + Assert.False(StringValues.IsNullOrEmpty(nonceValues)); + Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge"); + } + + [Fact] + public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; + + using var rsa = RSA.Create(2048); + var certificateRequest = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + var hexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = hexThumbprint + }); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var auditSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(); + var tokenStore = new TestTokenStore(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + auditSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + validator, + httpContextAccessor, + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected, context.ErrorDescription ?? context.Error); + Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]); + + var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256)); + Assert.Equal(expectedBase64, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]); + Assert.Equal(hexThumbprint, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty]); + } + + [Fact] + public async Task ValidateClientCredentials_RejectsMtlsClient_WhenCertificateMissing() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + validator, + httpContextAccessor, + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + } + + [Fact] + public async Task ValidateClientCredentials_Rejects_WhenAudienceRequiresMtlsButClientConfiguredForDpop() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Mtls.Enabled = true; + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Requested audiences require mutual TLS sender constraint.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateClientCredentials_RequiresMtlsWhenAudienceMatchesEnforcement() + { + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Mtls.Enabled = true; + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear(); + opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer"); + }); + + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + allowedAudiences: "signer"); + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = "DEADBEEF" + }); + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var certificateValidator = new RecordingCertificateValidator(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + + var handler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + new TestAuthEventSink(), + new TestRateLimiterMetadataAccessor(), + new TestServiceAccountStore(), + new TestTokenStore(), + TimeProvider.System, + certificateValidator, + httpContextAccessor, + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("client_certificate_required", context.ErrorDescription); + Assert.True(certificateValidator.Invoked); + } + + [Fact] + public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims() + { + var clientDocument = CreateClient( + secret: null, + clientType: "public", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:trigger", + allowedAudiences: "signer", + tenant: "Tenant-Alpha"); + + var descriptor = CreateDescriptor(clientDocument); + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); + var tokenStore = new TestTokenStore(); + var sessionAccessor = new NullMongoSessionAccessor(); + var authSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(); + var options = TestHelpers.CreateAuthorityOptions(); + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + authSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger"); + transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestInstruments.ActivitySource, NullLogger.Instance); + + var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRequestHandled); + Assert.NotNull(context.Principal); + Assert.Contains("signer", context.Principal!.GetAudiences()); + + Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success); + + var identityProviderClaim = context.Principal?.GetClaim(StellaOpsClaimTypes.IdentityProvider); + Assert.Equal(clientDocument.Plugin, identityProviderClaim); + + var principal = context.Principal ?? throw new InvalidOperationException("Principal missing"); + Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); + var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); + Assert.False(string.IsNullOrWhiteSpace(tokenId)); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = principal, + AccessTokenPrincipal = principal + }; + + await persistHandler.HandleAsync(signInContext); + + var persisted = Assert.IsType(tokenStore.Inserted); + Assert.Equal(tokenId, persisted.TokenId); + Assert.Equal(clientDocument.ClientId, persisted.ClientId); + Assert.Equal("valid", persisted.Status); + Assert.Equal("tenant-alpha", persisted.Tenant); + Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope); + } + + [Fact] + public async Task HandleClientCredentials_PersistsServiceAccountMetadata() + { + var clientDocument = CreateClient( + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "jobs:read", + tenant: "tenant-alpha"); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-ops", + Tenant = "tenant-alpha", + AllowedScopes = new List { "jobs:read" }, + AuthorizedClients = new List { clientDocument.ClientId } + }; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var tokenStore = new TestTokenStore(); + var sessionAccessor = new NullMongoSessionAccessor(); + var authSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 5; + }); + + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + authSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); + transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(10); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops"); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestInstruments.ActivitySource, NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + Assert.True(handleContext.IsRequestHandled); + + var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = handleContext.Principal, + AccessTokenPrincipal = handleContext.Principal + }; + + await persistHandler.HandleAsync(signInContext); + + var inserted = tokenStore.Inserted; + Assert.NotNull(inserted); + Assert.Equal("svc-ops", inserted!.ServiceAccountId); + Assert.Equal("service_account", inserted.TokenKind); + Assert.NotNull(inserted.ActorChain); + Assert.Contains(clientDocument.ClientId, inserted.ActorChain!); + Assert.Equal("tenant-alpha", inserted.Tenant); + Assert.Contains("jobs:read", inserted.Scope); } [Fact] @@ -2933,7 +3191,7 @@ public class ClientCredentialsHandlersTests var validateHandler = new ValidateClientCredentialsHandler( new TestClientStore(clientDocument), registry, - TestActivitySource, + TestInstruments.ActivitySource, authSink, metadataAccessor, serviceAccountStore, @@ -2961,9 +3219,9 @@ public class ClientCredentialsHandlersTests sessionAccessor, metadataAccessor, TimeProvider.System, - TestActivitySource, + TestInstruments.ActivitySource, NullLogger.Instance); - var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger.Instance); + var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestInstruments.ActivitySource, NullLogger.Instance); var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); await handleHandler.HandleAsync(handleContext); @@ -3000,1528 +3258,1663 @@ public class ClientCredentialsHandlersTests { var clientDocument = CreateClient( clientId: "vuln-explorer-worker", - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "vuln:view vuln:investigate", - tenant: "tenant-alpha"); - - var serviceAccount = new AuthorityServiceAccountDocument - { - AccountId = "svc-vuln", - Tenant = "tenant-alpha", - AllowedScopes = new List { "vuln:view", "vuln:investigate" }, - AuthorizedClients = new List { clientDocument.ClientId }, - Attributes = new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - ["env"] = new List { "Prod", "stage" }, - ["owner"] = new List { "SecOps" }, - ["business_tier"] = new List { "*" }, - ["ignored"] = new List { "value" } - } - }; - - var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); - var tokenStore = new TestTokenStore(); - var sessionAccessor = new NullMongoSessionAccessor(); - var authSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var serviceAccountStore = new TestServiceAccountStore(serviceAccount); - var options = TestHelpers.CreateAuthorityOptions(opts => - { - opts.Delegation.Quotas.MaxActiveTokens = 5; - }); - - var validateHandler = new ValidateClientCredentialsHandler( - new TestClientStore(clientDocument), - registry, - TestActivitySource, - authSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - TimeProvider.System, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - - var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view vuln:investigate"); - SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "secops"); - SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleHandler = new HandleClientCredentialsHandler( - registry, - tokenStore, - sessionAccessor, - metadataAccessor, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - - Assert.True(handleContext.IsRequestHandled); - var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing"); - - var envClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment).Select(c => c.Value).ToArray(); - Assert.Equal(new[] { "prod" }, envClaims); - - var ownerClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner).Select(c => c.Value).ToArray(); - Assert.Equal(new[] { "secops" }, ownerClaims); - - var tierClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier).Select(c => c.Value).ToArray(); - Assert.Equal(new[] { "tier-1" }, tierClaims); - } -} - -public class TokenValidationHandlersTests -{ - private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.TokenValidation"); - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked() - { - var tokenStore = new TestTokenStore(); - tokenStore.Inserted = new AuthorityTokenDocument - { - TokenId = "token-1", - Status = "revoked", - ClientId = "concelier" - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(CreateClient()), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal("concelier", "token-1", "standard"); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-1" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Fact] - public async Task ValidateAccessTokenHandler_AddsTenantClaim_FromTokenDocument() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha" - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); - Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); - } - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenTenantDiffersFromToken() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha" - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); - principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-beta")); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - Assert.Equal("The token tenant does not match the issued tenant.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateAccessTokenHandler_AssignsTenant_FromClientWhenTokenMissing() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); - Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); - } - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenClientTenantDiffers() - { - var clientDocument = CreateClient(tenant: "tenant-beta"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-tenant", - Status = "valid", - ClientId = clientDocument.ClientId - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); - principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha")); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-tenant" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription); - } - - [Fact] - public async Task ValidateAccessTokenHandler_EnrichesClaims_WhenProviderAvailable() - { - var clientDocument = CreateClient(); - var userDescriptor = new AuthorityUserDescriptor("user-1", "alice", displayName: "Alice", requiresPasswordReset: false); - - var plugin = CreatePlugin( - name: "standard", - supportsClientProvisioning: true, - descriptor: CreateDescriptor(clientDocument), - user: userDescriptor); - - var registry = CreateRegistryFromPlugins(plugin); - - var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor(); - var auditSinkSuccess = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - new TestTokenStore(), - sessionAccessor, - new TestClientStore(clientDocument), - registry, - metadataAccessorSuccess, - auditSinkSuccess, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-123", plugin.Name, subject: userDescriptor.SubjectId); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true"); - } - - [Fact] - public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken() - { - var tokenDocument = new AuthorityTokenDocument - { - TokenId = "token-mtls", - Status = "valid", - ClientId = "mtls-client", - SenderConstraint = AuthoritySenderConstraintKinds.Mtls, - SenderKeyThumbprint = "thumb-print" - }; - - var tokenStore = new TestTokenStore - { - Inserted = tokenDocument - }; - - var clientDocument = CreateClient(); - var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - registry, - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Introspection, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, ResolveProvider(clientDocument)); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = tokenDocument.TokenId - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); - Assert.False(string.IsNullOrWhiteSpace(confirmation)); - using var json = JsonDocument.Parse(confirmation!); - Assert.Equal(tokenDocument.SenderKeyThumbprint, json.RootElement.GetProperty("x5t#S256").GetString()); - } - - [Fact] - public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay() - { - var tokenStore = new TestTokenStore(); - tokenStore.Inserted = new AuthorityTokenDocument - { - TokenId = "token-replay", - Status = "valid", - ClientId = "agent", - Devices = new List - { - new BsonDocument - { - { "remoteAddress", "10.0.0.1" }, - { "userAgent", "agent/1.0" }, - { "firstSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-15)) }, - { "lastSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-5)) }, - { "useCount", 2 } - } - } - }; - - tokenStore.UsageCallback = (remote, agent) => new TokenUsageUpdateResult(TokenUsageUpdateStatus.SuspectedReplay, remote, agent); - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var metadata = metadataAccessor.GetMetadata(); - if (metadata is not null) - { - metadata.RemoteIp = "203.0.113.7"; - metadata.UserAgent = "agent/2.0"; - } - - var clientDocument = CreateClient(); - clientDocument.ClientId = "agent"; - var auditSink = new TestAuthEventSink(); - var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); - var sessionAccessorReplay = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessorReplay, - new TestClientStore(clientDocument), - registry, - metadataAccessor, - auditSink, - TimeProvider.System, - TestActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Introspection, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal("agent", "token-replay", "standard"); - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-replay" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - var replayEvent = Assert.Single(auditSink.Events, record => record.EventType == "authority.token.replay.suspected"); - Assert.Equal(AuthEventOutcome.Error, replayEvent.Outcome); - Assert.NotNull(replayEvent.Network); - Assert.Equal("203.0.113.7", replayEvent.Network?.RemoteAddress.Value); - Assert.Contains(replayEvent.Properties, property => property.Name == "token.devices.total"); - } -} - -public class AuthorityClientCertificateValidatorTests -{ - [Fact] - public async Task ValidateAsync_Rejects_WhenSanTypeNotAllowed() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); - options.Security.SenderConstraints.Mtls.AllowedSanTypes.Add("uri"); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)) - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.False(result.Succeeded); - Assert.Equal("certificate_san_type", result.Error); - } - - [Fact] - public async Task ValidateAsync_AllowsBindingWithinRotationGrace() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Security.SenderConstraints.Mtls.RotationGrace = TimeSpan.FromMinutes(5); - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(10)); - - var thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = thumbprint, - NotBefore = TimeProvider.System.GetUtcNow().AddMinutes(2) - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.True(result.Succeeded); - Assert.Equal(thumbprint, result.HexThumbprint); - } - - [Fact] - public async Task ValidateAsync_Rejects_WhenBindingSubjectMismatch() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), - Subject = "CN=different-client" - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.False(result.Succeeded); - Assert.Equal("certificate_binding_subject_mismatch", result.Error); - } - - [Fact] - public async Task ValidateAsync_Rejects_WhenBindingSansMissing() - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - options.Security.SenderConstraints.Mtls.Enabled = true; - options.Security.SenderConstraints.Mtls.RequireChainValidation = false; - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("client.mtls.test"); - request.CertificateExtensions.Add(sanBuilder.Build()); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); - - var clientDocument = CreateClient(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; - clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding - { - Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), - SubjectAlternativeNames = new List { "spiffe://client" } - }); - - var httpContext = new DefaultHttpContext(); - httpContext.Connection.ClientCertificate = certificate; - - var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); - var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); - - Assert.False(result.Succeeded); - Assert.Equal("certificate_binding_san_mismatch", result.Error); - } -} - -internal sealed class TestClientStore : IAuthorityClientStore -{ - private readonly Dictionary clients = new(StringComparer.OrdinalIgnoreCase); - - public TestClientStore(params AuthorityClientDocument[] documents) - { - foreach (var document in documents) - { - clients[document.ClientId] = document; - } - } - - public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - clients.TryGetValue(clientId, out var document); - return ValueTask.FromResult(document); - } - - public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - clients[document.ClientId] = document; - return ValueTask.CompletedTask; - } - - public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(clients.Remove(clientId)); -} - -internal sealed class TestServiceAccountStore : IAuthorityServiceAccountStore -{ - private readonly Dictionary accounts = new(StringComparer.OrdinalIgnoreCase); - - public TestServiceAccountStore(params AuthorityServiceAccountDocument[] documents) - { - foreach (var document in documents) - { - accounts[NormalizeKey(document.AccountId)] = document; - } - } - - public ValueTask FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (string.IsNullOrWhiteSpace(accountId)) - { - return ValueTask.FromResult(null); - } - - accounts.TryGetValue(NormalizeKey(accountId), out var document); - return ValueTask.FromResult(document); - } - - public ValueTask> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (string.IsNullOrWhiteSpace(tenant)) - { - return ValueTask.FromResult>(Array.Empty()); - } - - var normalizedTenant = tenant.Trim().ToLowerInvariant(); - var results = accounts.Values - .Where(account => string.Equals(account.Tenant, normalizedTenant, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - return ValueTask.FromResult>(results); - } - - public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - ArgumentNullException.ThrowIfNull(document); - accounts[NormalizeKey(document.AccountId)] = document; - return ValueTask.CompletedTask; - } - - public ValueTask DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (string.IsNullOrWhiteSpace(accountId)) - { - return ValueTask.FromResult(false); - } - - return ValueTask.FromResult(accounts.Remove(NormalizeKey(accountId))); - } - - private static string NormalizeKey(string value) - => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant(); -} - -internal sealed class TestTokenStore : IAuthorityTokenStore -{ - public AuthorityTokenDocument? Inserted { get; set; } - - public Func? UsageCallback { get; set; } - - public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - Inserted = document; - return ValueTask.CompletedTask; - } - - public ValueTask FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null); - - public ValueTask FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(null); - - public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.CompletedTask; - - public ValueTask DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(0L); - - public ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent)); - - public ValueTask> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult>(Array.Empty()); - public ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (Inserted is null) - { - return ValueTask.FromResult>(Array.Empty()); - } - - var scopeMatches = Inserted.Scope is not null && Inserted.Scope.Any(s => string.Equals(s, scope, StringComparison.OrdinalIgnoreCase)); - var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); - var issuedAfterMatches = !issuedAfter.HasValue || Inserted.CreatedAt >= issuedAfter.Value; - - if (scopeMatches && tenantMatches && issuedAfterMatches) - { - return ValueTask.FromResult>(new[] { Inserted }); - } - - return ValueTask.FromResult>(Array.Empty()); - } - - public ValueTask CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (Inserted is null) - { - return ValueTask.FromResult(0L); - } - - var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); - var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || - string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); - var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && - (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && - !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && - string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); - - return ValueTask.FromResult(tenantMatches && accountMatches && active ? 1L : 0L); - } - - public ValueTask> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (Inserted is null) - { - return ValueTask.FromResult>(Array.Empty()); - } - - var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); - var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || - string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); - var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && - (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && - !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && - string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); - - if (tenantMatches && accountMatches && active) - { - return ValueTask.FromResult>(new[] { Inserted }); - } - - return ValueTask.FromResult>(Array.Empty()); - } - -} - -internal sealed class TestClaimsEnricher : IClaimsEnricher -{ - public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken) - { - if (!identity.HasClaim(c => c.Type == "enriched")) - { - identity.AddClaim(new Claim("enriched", "true")); - } - - return ValueTask.CompletedTask; - } -} - -internal sealed class TestUserCredentialStore : IUserCredentialStore -{ - private readonly AuthorityUserDescriptor? user; - - public TestUserCredentialStore(AuthorityUserDescriptor? user) - { - this.user = user; - } - - public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials)); - - public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); - - public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) - => ValueTask.FromResult(user); -} - -internal sealed class TestClientProvisioningStore : IClientProvisioningStore -{ - private readonly AuthorityClientDescriptor? descriptor; - - public TestClientProvisioningStore(AuthorityClientDescriptor? descriptor) - { - this.descriptor = descriptor; - } - - public ValueTask> CreateOrUpdateAsync(AuthorityClientRegistration registration, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); - - public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken) - => ValueTask.FromResult(descriptor); - - public ValueTask DeleteAsync(string clientId, CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginOperationResult.Success()); -} - -internal sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin -{ - public TestIdentityProviderPlugin( - AuthorityPluginContext context, - IUserCredentialStore credentialStore, - IClaimsEnricher claimsEnricher, - IClientProvisioningStore? clientProvisioning, - AuthorityIdentityProviderCapabilities capabilities) - { - Context = context; - Credentials = credentialStore; - ClaimsEnricher = claimsEnricher; - ClientProvisioning = clientProvisioning; - Capabilities = capabilities; - } - - public string Name => Context.Manifest.Name; - - public string Type => Context.Manifest.Type; - - public AuthorityPluginContext Context { get; } - - public IUserCredentialStore Credentials { get; } - - public IClaimsEnricher ClaimsEnricher { get; } - - public IClientProvisioningStore? ClientProvisioning { get; } - - public AuthorityIdentityProviderCapabilities Capabilities { get; } - - public ValueTask CheckHealthAsync(CancellationToken cancellationToken) - => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); -} - -internal sealed class TestAuthEventSink : IAuthEventSink -{ - public List Events { get; } = new(); - - public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) - { - Events.Add(record); - return ValueTask.CompletedTask; - } -} - -internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor -{ - private readonly AuthorityRateLimiterMetadata metadata = new(); - - public AuthorityRateLimiterMetadata? GetMetadata() => metadata; - - public void SetClientId(string? clientId) => metadata.ClientId = clientId; - - public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId; - - public void SetTenant(string? tenant) - { - metadata.Tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim().ToLowerInvariant(); - metadata.SetTag("authority.tenant", metadata.Tenant); - } - - public void SetProject(string? project) - { - metadata.Project = string.IsNullOrWhiteSpace(project) ? null : project.Trim().ToLowerInvariant(); - metadata.SetTag("authority.project", metadata.Project); - } - - public void SetTag(string name, string? value) => metadata.SetTag(name, value); -} - -internal sealed class NoopCertificateValidator : IAuthorityClientCertificateValidator -{ - public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) - { - var binding = new AuthorityClientCertificateBinding - { - Thumbprint = "stub" - }; - - return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", "stub", binding)); - } -} - -internal sealed class RecordingCertificateValidator : IAuthorityClientCertificateValidator -{ - public bool Invoked { get; private set; } - - public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) - { - Invoked = true; - - if (httpContext.Connection.ClientCertificate is null) - { - return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required")); - } - - AuthorityClientCertificateBinding binding; - if (client.CertificateBindings.Count > 0) - { - binding = client.CertificateBindings[0]; - } - else - { - binding = new AuthorityClientCertificateBinding { Thumbprint = "stub" }; - } - - return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", binding.Thumbprint, binding)); - } -} - -internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor -{ - public ValueTask GetSessionAsync(CancellationToken cancellationToken = default) - => ValueTask.FromResult(null!); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} - -public class ObservabilityIncidentTokenHandlerTests -{ - private static readonly ActivitySource ActivitySource = new("StellaOps.Authority.Tests"); - - [Fact] - public async Task ValidateAccessTokenHandler_Rejects_WhenObsIncidentNotFresh() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-incident", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { StellaOpsScopes.ObservabilityIncident }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15) - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-incident", ResolveProvider(clientDocument)); - principal.SetScopes(StellaOpsScopes.ObservabilityIncident); - var staleAuthTime = DateTimeOffset.UtcNow.AddMinutes(-10); - principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuthTime.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-incident" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Fact] - public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationMissingClaims() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-policy", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { StellaOpsScopes.PolicyPublish }, - CreatedAt = DateTimeOffset.UtcNow - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-policy", ResolveProvider(clientDocument)); - principal.SetScopes(StellaOpsScopes.PolicyPublish); - principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue); - principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy"); - principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2000"); - principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-policy" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Fact] - public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationNotFresh() - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = "token-policy-stale", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { StellaOpsScopes.PolicyPublish }, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20) - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, "token-policy-stale", ResolveProvider(clientDocument)); - principal.SetScopes(StellaOpsScopes.PolicyPublish); - principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue); - principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('a', 64)); - principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy"); - principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2001"); - var staleAuth = DateTimeOffset.UtcNow.AddMinutes(-10); - principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuth.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = "token-policy-stale" - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - } - - [Theory] - [InlineData(StellaOpsScopes.PolicyPublish, AuthorityOpenIddictConstants.PolicyOperationPublishValue)] - [InlineData(StellaOpsScopes.PolicyPromote, AuthorityOpenIddictConstants.PolicyOperationPromoteValue)] - public async Task ValidateAccessTokenHandler_AllowsPolicyAttestationWithMetadata(string scope, string expectedOperation) - { - var clientDocument = CreateClient(tenant: "tenant-alpha"); - var tokenStore = new TestTokenStore - { - Inserted = new AuthorityTokenDocument - { - TokenId = $"token-{expectedOperation}", - Status = "valid", - ClientId = clientDocument.ClientId, - Tenant = "tenant-alpha", - Scope = new List { scope }, - CreatedAt = DateTimeOffset.UtcNow - } - }; - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - var sessionAccessor = new NullMongoSessionAccessor(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - new TestClientStore(clientDocument), - CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), - metadataAccessor, - auditSink, - TimeProvider.System, - ActivitySource, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - Options = new OpenIddictServerOptions(), - EndpointType = OpenIddictServerEndpointType.Token, - Request = new OpenIddictRequest() - }; - - var principal = CreatePrincipal(clientDocument.ClientId, $"token-{expectedOperation}", ResolveProvider(clientDocument)); - principal.SetScopes(scope); - principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, expectedOperation); - principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('b', 64)); - principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Promotion approved"); - principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2002"); - principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = $"token-{expectedOperation}" - }; - - await handler.HandleAsync(context); - - Assert.False(context.IsRejected); - var metadata = metadataAccessor.GetMetadata(); - Assert.NotNull(metadata); - Assert.True(metadata!.Tags.TryGetValue("authority.policy_attestation_validated", out var tagValue)); - Assert.Equal(expectedOperation.ToLowerInvariant(), tagValue); - } - - [Fact] - public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope() - { - var handler = new ValidateRefreshTokenGrantHandler(NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - EndpointType = OpenIddictServerEndpointType.Token, - Options = new OpenIddictServerOptions(), - Request = new OpenIddictRequest - { - GrantType = OpenIddictConstants.GrantTypes.RefreshToken - } - }; - - var principal = CreatePrincipal("cli-app", "refresh-token", "standard"); - principal.SetScopes(StellaOpsScopes.ObservabilityIncident); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction) - { - Principal = principal - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidGrant, context.Error); - } -} - -internal static class TestHelpers -{ - public static StellaOpsAuthorityOptions CreateAuthorityOptions(Action? configure = null) - { - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test") - }; - - options.Signing.ActiveKeyId = "test-key"; - options.Signing.KeyPath = "/tmp/test-key.pem"; - options.Storage.ConnectionString = "mongodb://localhost/test"; - - configure?.Invoke(options); - return options; - } - - public static AuthorityClientDocument CreateClient( - string clientId = "concelier", - string? secret = "s3cr3t!", - string clientType = "confidential", - string allowedGrantTypes = "client_credentials", - string allowedScopes = "jobs:read", - string allowedAudiences = "", - string? tenant = null) - { - var document = new AuthorityClientDocument - { - ClientId = clientId, - ClientType = clientType, - SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret), - Plugin = "standard", - Properties = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [AuthorityClientMetadataKeys.AllowedGrantTypes] = allowedGrantTypes, - [AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes - } - }; - - if (!string.IsNullOrWhiteSpace(allowedAudiences)) - { - document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences; - } - - var normalizedTenant = NormalizeTenant(tenant); - if (normalizedTenant is not null) - { - document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; - } - - return document; - } - - private static string? NormalizeTenant(string? value) - => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); - - public static string ResolveProvider(AuthorityClientDocument document) - => string.IsNullOrWhiteSpace(document.Plugin) ? "standard" : document.Plugin; - - private static OpenIddictRequest GetRequest(OpenIddictServerTransaction transaction) - => transaction.Request ?? throw new InvalidOperationException("OpenIddict request is required for this test."); - - public static void SetParameter(OpenIddictServerTransaction transaction, string name, object? value) - { - var parameter = value switch - { - null => default, - OpenIddictParameter existing => existing, - string s => new OpenIddictParameter(s), - bool b => new OpenIddictParameter(b), - int i => new OpenIddictParameter(i), - long l => new OpenIddictParameter(l), - _ => new OpenIddictParameter(value?.ToString()) - }; - GetRequest(transaction).SetParameter(name, parameter); - } - - public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document) - { - var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); - var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); - var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); - - return new AuthorityClientDescriptor( - document.ClientId, - document.DisplayName, - confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase), - allowedGrantTypes, - allowedScopes, - allowedAudiences, - redirectUris: Array.Empty(), - postLogoutRedirectUris: Array.Empty(), - properties: document.Properties); - } - - public static AuthorityIdentityProviderRegistry CreateRegistry(bool withClientProvisioning, AuthorityClientDescriptor? clientDescriptor) - { - var plugin = CreatePlugin( - name: "standard", - supportsClientProvisioning: withClientProvisioning, - descriptor: clientDescriptor, - user: null); - - return CreateRegistryFromPlugins(plugin); - } - - public static TestIdentityProviderPlugin CreatePlugin( - string name, - bool supportsClientProvisioning, - AuthorityClientDescriptor? descriptor, - AuthorityUserDescriptor? user) - { - var capabilities = supportsClientProvisioning - ? new[] { AuthorityPluginCapabilities.ClientProvisioning } - : Array.Empty(); - - var manifest = new AuthorityPluginManifest( - name, - "standard", - true, - null, - null, - capabilities, - new Dictionary(StringComparer.OrdinalIgnoreCase), - $"{name}.yaml"); - - var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); - - return new TestIdentityProviderPlugin( - context, - new TestUserCredentialStore(user), - new TestClaimsEnricher(), - supportsClientProvisioning ? new TestClientProvisioningStore(descriptor) : null, - new AuthorityIdentityProviderCapabilities( - SupportsPassword: true, - SupportsMfa: false, - SupportsClientProvisioning: supportsClientProvisioning)); - } - - public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins) - { - var services = new ServiceCollection(); - services.AddLogging(); - foreach (var plugin in plugins) - { - services.AddSingleton(plugin); - } - - var provider = services.BuildServiceProvider(); - return new AuthorityIdentityProviderRegistry(provider, NullLogger.Instance); - } - - public static OpenIddictServerTransaction CreateTokenTransaction(string clientId, string? secret, string? scope) - { - var request = new OpenIddictRequest - { - GrantType = OpenIddictConstants.GrantTypes.ClientCredentials, - ClientId = clientId, - ClientSecret = secret - }; - - if (!string.IsNullOrWhiteSpace(scope)) - { - request.Scope = scope; - } - - return new OpenIddictServerTransaction - { - EndpointType = OpenIddictServerEndpointType.Token, - Options = new OpenIddictServerOptions(), - Request = request - }; - } - - public static string ConvertThumbprintToString(object thumbprint) - => thumbprint switch - { - string value => value, - byte[] bytes => Base64UrlEncoder.Encode(bytes), - _ => throw new InvalidOperationException("Unsupported thumbprint representation.") - }; - - public static string CreateDpopProof(ECDsaSecurityKey key, string method, string url, long issuedAt, string? nonce = null) - { - var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key); - jwk.KeyId ??= key.KeyId ?? Guid.NewGuid().ToString("N"); - - var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); - var header = new JwtHeader(signingCredentials) - { - ["typ"] = "dpop+jwt", - ["jwk"] = new Dictionary - { - ["kty"] = jwk.Kty, - ["crv"] = jwk.Crv, - ["x"] = jwk.X, - ["y"] = jwk.Y, - ["kid"] = jwk.Kid ?? jwk.KeyId - } - }; - - var payload = new JwtPayload - { - ["htm"] = method.ToUpperInvariant(), - ["htu"] = url, - ["iat"] = issuedAt, - ["jti"] = Guid.NewGuid().ToString("N") - }; - - if (!string.IsNullOrWhiteSpace(nonce)) - { - payload["nonce"] = nonce; - } - - var token = new JwtSecurityToken(header, payload); - return new JwtSecurityTokenHandler().WriteToken(token); - } - - public static X509Certificate2 CreateTestCertificate(string subjectName) - { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); - } - - public static ClaimsPrincipal CreatePrincipal(string clientId, string tokenId, string provider, string? subject = null) - { - var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId)); - identity.AddClaim(new Claim(OpenIddictConstants.Claims.JwtId, tokenId)); - identity.AddClaim(new Claim(StellaOpsClaimTypes.IdentityProvider, provider)); - identity.AddClaim(new Claim(StellaOpsClaimTypes.Project, StellaOpsTenancyDefaults.AnyProject)); - - if (!string.IsNullOrWhiteSpace(subject)) - { - identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, subject)); - } - - return new ClaimsPrincipal(identity); - } -} + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials", + allowedScopes: "vuln:view vuln:investigate", + tenant: "tenant-alpha"); + + var serviceAccount = new AuthorityServiceAccountDocument + { + AccountId = "svc-vuln", + Tenant = "tenant-alpha", + AllowedScopes = new List { "vuln:view", "vuln:investigate" }, + AuthorizedClients = new List { clientDocument.ClientId }, + Attributes = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["env"] = new List { "Prod", "stage" }, + ["owner"] = new List { "SecOps" }, + ["business_tier"] = new List { "*" }, + ["ignored"] = new List { "value" } + } + }; + + var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); + var tokenStore = new TestTokenStore(); + var sessionAccessor = new NullMongoSessionAccessor(); + var authSink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var serviceAccountStore = new TestServiceAccountStore(serviceAccount); + var options = TestHelpers.CreateAuthorityOptions(opts => + { + opts.Delegation.Quotas.MaxActiveTokens = 5; + }); + + var validateHandler = new ValidateClientCredentialsHandler( + new TestClientStore(clientDocument), + registry, + TestInstruments.ActivitySource, + authSink, + metadataAccessor, + serviceAccountStore, + tokenStore, + TimeProvider.System, + new NoopCertificateValidator(), + new HttpContextAccessor(), + options, + NullLogger.Instance); + + var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view vuln:investigate"); + SetParameter(transaction, AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnOwnerParameterName, "secops"); + SetParameter(transaction, AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1"); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validateHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handleHandler = new HandleClientCredentialsHandler( + registry, + tokenStore, + sessionAccessor, + metadataAccessor, + TimeProvider.System, + TestInstruments.ActivitySource, + NullLogger.Instance); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handleHandler.HandleAsync(handleContext); + + Assert.True(handleContext.IsRequestHandled); + var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing"); + + var envClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment).Select(c => c.Value).ToArray(); + Assert.Equal(new[] { "prod" }, envClaims); + + var ownerClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner).Select(c => c.Value).ToArray(); + Assert.Equal(new[] { "secops" }, ownerClaims); + + var tierClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier).Select(c => c.Value).ToArray(); + Assert.Equal(new[] { "tier-1" }, tierClaims); + } +} + +public class TokenValidationHandlersTests +{ + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked() + { + var tokenStore = new TestTokenStore(); + tokenStore.Inserted = new AuthorityTokenDocument + { + TokenId = "token-1", + Status = "revoked", + ClientId = "concelier" + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(CreateClient()), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())), + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal("concelier", "token-1", "standard"); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-1" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Fact] + public async Task ValidateAccessTokenHandler_AddsTenantClaim_FromTokenDocument() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha" + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); + Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project)); + Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); + } + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenTenantDiffersFromToken() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha" + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); + principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-beta")); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + Assert.Equal("The token tenant does not match the issued tenant.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateAccessTokenHandler_AssignsTenant_FromClientWhenTokenMissing() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); + Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); + } + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenClientTenantDiffers() + { + var clientDocument = CreateClient(tenant: "tenant-beta"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-tenant", + Status = "valid", + ClientId = clientDocument.ClientId + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument)); + principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha")); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-tenant" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateAccessTokenHandler_EnrichesClaims_WhenProviderAvailable() + { + var clientDocument = CreateClient(); + var userDescriptor = new AuthorityUserDescriptor("user-1", "alice", displayName: "Alice", requiresPasswordReset: false); + + var plugin = CreatePlugin( + name: "standard", + supportsClientProvisioning: true, + descriptor: CreateDescriptor(clientDocument), + user: userDescriptor); + + var registry = CreateRegistryFromPlugins(plugin); + + var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor(); + var auditSinkSuccess = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + new TestTokenStore(), + sessionAccessor, + new TestClientStore(clientDocument), + registry, + metadataAccessorSuccess, + auditSinkSuccess, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-123", plugin.Name, subject: userDescriptor.SubjectId); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true"); + } + + [Fact] + public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken() + { + var tokenDocument = new AuthorityTokenDocument + { + TokenId = "token-mtls", + Status = "valid", + ClientId = "mtls-client", + SenderConstraint = AuthoritySenderConstraintKinds.Mtls, + SenderKeyThumbprint = "thumb-print", + SenderCertificateHex = "ABCDEF1234" + }; + + var tokenStore = new TestTokenStore + { + Inserted = tokenDocument + }; + + var clientDocument = CreateClient(); + var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + registry, + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Introspection, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = tokenDocument.TokenId + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); + Assert.False(string.IsNullOrWhiteSpace(confirmation)); + using var json = JsonDocument.Parse(confirmation!); + Assert.Equal(tokenDocument.SenderKeyThumbprint, json.RootElement.GetProperty("x5t#S256").GetString()); + Assert.Equal(tokenDocument.SenderCertificateHex, context.Principal?.GetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType)); + } + + [Fact] + public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay() + { + var tokenStore = new TestTokenStore(); + tokenStore.Inserted = new AuthorityTokenDocument + { + TokenId = "token-replay", + Status = "valid", + ClientId = "agent", + Devices = new List + { + new BsonDocument + { + { "remoteAddress", "10.0.0.1" }, + { "userAgent", "agent/1.0" }, + { "firstSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-15)) }, + { "lastSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-5)) }, + { "useCount", 2 } + } + } + }; + + tokenStore.UsageCallback = (remote, agent) => new TokenUsageUpdateResult(TokenUsageUpdateStatus.SuspectedReplay, remote, agent); + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var metadata = metadataAccessor.GetMetadata(); + if (metadata is not null) + { + metadata.RemoteIp = "203.0.113.7"; + metadata.UserAgent = "agent/2.0"; + } + + var clientDocument = CreateClient(); + clientDocument.ClientId = "agent"; + var auditSink = new TestAuthEventSink(); + var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null); + var sessionAccessorReplay = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessorReplay, + new TestClientStore(clientDocument), + registry, + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Introspection, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal("agent", "token-replay", "standard"); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-replay" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + var replayEvent = Assert.Single(auditSink.Events, record => record.EventType == "authority.token.replay.suspected"); + Assert.Equal(AuthEventOutcome.Error, replayEvent.Outcome); + Assert.NotNull(replayEvent.Network); + Assert.Equal("203.0.113.7", replayEvent.Network?.RemoteAddress.Value); + Assert.Contains(replayEvent.Properties, property => property.Name == "token.devices.total"); + } +} + +public class AuthorityClientCertificateValidatorTests +{ + [Fact] + public async Task ValidateAsync_Rejects_WhenSanTypeNotAllowed() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear(); + options.Security.SenderConstraints.Mtls.AllowedSanTypes.Add("uri"); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)) + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal("certificate_san_type", result.Error); + } + + [Fact] + public async Task ValidateAsync_AllowsBindingWithinRotationGrace() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Security.SenderConstraints.Mtls.RotationGrace = TimeSpan.FromMinutes(5); + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(10)); + + var thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = thumbprint, + NotBefore = TimeProvider.System.GetUtcNow().AddMinutes(2) + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(thumbprint, result.HexThumbprint); + } + + [Fact] + public async Task ValidateAsync_Rejects_WhenBindingSubjectMismatch() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), + Subject = "CN=different-client" + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal("certificate_binding_subject_mismatch", result.Error); + } + + [Fact] + public async Task ValidateAsync_Rejects_WhenBindingSansMissing() + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + options.Security.SenderConstraints.Mtls.Enabled = true; + options.Security.SenderConstraints.Mtls.RequireChainValidation = false; + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5)); + + var clientDocument = CreateClient(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding + { + Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)), + SubjectAlternativeNames = new List { "spiffe://client" } + }); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger.Instance); + var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal("certificate_binding_san_mismatch", result.Error); + } +} + +internal sealed class TestClientStore : IAuthorityClientStore +{ + private readonly Dictionary clients = new(StringComparer.OrdinalIgnoreCase); + + public TestClientStore(params AuthorityClientDocument[] documents) + { + foreach (var document in documents) + { + clients[document.ClientId] = document; + } + } + + public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + clients.TryGetValue(clientId, out var document); + return ValueTask.FromResult(document); + } + + public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + clients[document.ClientId] = document; + return ValueTask.CompletedTask; + } + + public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(clients.Remove(clientId)); +} + +internal sealed class TestServiceAccountStore : IAuthorityServiceAccountStore +{ + private readonly Dictionary accounts = new(StringComparer.OrdinalIgnoreCase); + + public TestServiceAccountStore(params AuthorityServiceAccountDocument[] documents) + { + foreach (var document in documents) + { + accounts[NormalizeKey(document.AccountId)] = document; + } + } + + public ValueTask FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (string.IsNullOrWhiteSpace(accountId)) + { + return ValueTask.FromResult(null); + } + + accounts.TryGetValue(NormalizeKey(accountId), out var document); + return ValueTask.FromResult(document); + } + + public ValueTask> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (string.IsNullOrWhiteSpace(tenant)) + { + return ValueTask.FromResult>(Array.Empty()); + } + + var normalizedTenant = tenant.Trim().ToLowerInvariant(); + var results = accounts.Values + .Where(account => string.Equals(account.Tenant, normalizedTenant, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return ValueTask.FromResult>(results); + } + + public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + ArgumentNullException.ThrowIfNull(document); + accounts[NormalizeKey(document.AccountId)] = document; + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (string.IsNullOrWhiteSpace(accountId)) + { + return ValueTask.FromResult(false); + } + + return ValueTask.FromResult(accounts.Remove(NormalizeKey(accountId))); + } + + private static string NormalizeKey(string value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant(); +} + +internal sealed class TestTokenStore : IAuthorityTokenStore +{ + public AuthorityTokenDocument? Inserted { get; set; } + + public Func? UsageCallback { get; set; } + + public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + Inserted = document; + return ValueTask.CompletedTask; + } + + public ValueTask FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null); + + public ValueTask FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(null); + + public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.CompletedTask; + + public ValueTask DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(0L); + + public ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent)); + + public ValueTask> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult>(Array.Empty()); + public ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (Inserted is null) + { + return ValueTask.FromResult>(Array.Empty()); + } + + var scopeMatches = Inserted.Scope is not null && Inserted.Scope.Any(s => string.Equals(s, scope, StringComparison.OrdinalIgnoreCase)); + var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); + var issuedAfterMatches = !issuedAfter.HasValue || Inserted.CreatedAt >= issuedAfter.Value; + + if (scopeMatches && tenantMatches && issuedAfterMatches) + { + return ValueTask.FromResult>(new[] { Inserted }); + } + + return ValueTask.FromResult>(Array.Empty()); + } + + public ValueTask CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (Inserted is null) + { + return ValueTask.FromResult(0L); + } + + var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); + var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || + string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); + var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && + (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && + !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && + string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); + + return ValueTask.FromResult(tenantMatches && accountMatches && active ? 1L : 0L); + } + + public ValueTask> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (Inserted is null) + { + return ValueTask.FromResult>(Array.Empty()); + } + + var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase); + var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) || + string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase); + var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) && + (!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) && + !string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) && + string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase); + + if (tenantMatches && accountMatches && active) + { + return ValueTask.FromResult>(new[] { Inserted }); + } + + return ValueTask.FromResult>(Array.Empty()); + } + +} + +internal sealed class TestClaimsEnricher : IClaimsEnricher +{ + public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken) + { + if (!identity.HasClaim(c => c.Type == "enriched")) + { + identity.AddClaim(new Claim("enriched", "true")); + } + + return ValueTask.CompletedTask; + } +} + +internal sealed class TestUserCredentialStore : IUserCredentialStore +{ + private readonly AuthorityUserDescriptor? user; + + public TestUserCredentialStore(AuthorityUserDescriptor? user) + { + this.user = user; + } + + public ValueTask VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials)); + + public ValueTask> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); + + public ValueTask FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) + => ValueTask.FromResult(user); +} + +internal sealed class TestClientProvisioningStore : IClientProvisioningStore +{ + private readonly AuthorityClientDescriptor? descriptor; + + public TestClientProvisioningStore(AuthorityClientDescriptor? descriptor) + { + this.descriptor = descriptor; + } + + public ValueTask> CreateOrUpdateAsync(AuthorityClientRegistration registration, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginOperationResult.Failure("unsupported", "not implemented")); + + public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken) + => ValueTask.FromResult(descriptor); + + public ValueTask DeleteAsync(string clientId, CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginOperationResult.Success()); +} + +internal sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin +{ + public TestIdentityProviderPlugin( + AuthorityPluginContext context, + IUserCredentialStore credentialStore, + IClaimsEnricher claimsEnricher, + IClientProvisioningStore? clientProvisioning, + AuthorityIdentityProviderCapabilities capabilities) + { + Context = context; + Credentials = credentialStore; + ClaimsEnricher = claimsEnricher; + ClientProvisioning = clientProvisioning; + Capabilities = capabilities; + } + + public string Name => Context.Manifest.Name; + + public string Type => Context.Manifest.Type; + + public AuthorityPluginContext Context { get; } + + public IUserCredentialStore Credentials { get; } + + public IClaimsEnricher ClaimsEnricher { get; } + + public IClientProvisioningStore? ClientProvisioning { get; } + + public AuthorityIdentityProviderCapabilities Capabilities { get; } + + public ValueTask CheckHealthAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); +} + +internal sealed class TestAuthEventSink : IAuthEventSink +{ + public List Events { get; } = new(); + + public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) + { + Events.Add(record); + return ValueTask.CompletedTask; + } +} + +internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor +{ + private readonly AuthorityRateLimiterMetadata metadata = new(); + + public AuthorityRateLimiterMetadata? GetMetadata() => metadata; + + public void SetClientId(string? clientId) => metadata.ClientId = clientId; + + public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId; + + public void SetTenant(string? tenant) + { + metadata.Tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim().ToLowerInvariant(); + metadata.SetTag("authority.tenant", metadata.Tenant); + } + + public void SetProject(string? project) + { + metadata.Project = string.IsNullOrWhiteSpace(project) ? null : project.Trim().ToLowerInvariant(); + metadata.SetTag("authority.project", metadata.Project); + } + + public void SetTag(string name, string? value) => metadata.SetTag(name, value); +} + +internal sealed class NoopCertificateValidator : IAuthorityClientCertificateValidator +{ + public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) + { + var binding = new AuthorityClientCertificateBinding + { + Thumbprint = "stub" + }; + + return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", "stub", binding)); + } +} + +internal sealed class RecordingCertificateValidator : IAuthorityClientCertificateValidator +{ + public bool Invoked { get; private set; } + + public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) + { + Invoked = true; + + if (httpContext.Connection.ClientCertificate is null) + { + return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required")); + } + + AuthorityClientCertificateBinding binding; + if (client.CertificateBindings.Count > 0) + { + binding = client.CertificateBindings[0]; + } + else + { + binding = new AuthorityClientCertificateBinding { Thumbprint = "stub" }; + } + + return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", binding.Thumbprint, binding)); + } +} + +internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor +{ + public ValueTask GetSessionAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult(null!); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +public class ObservabilityIncidentTokenHandlerTests +{ + private static readonly ActivitySource ActivitySource = new("StellaOps.Authority.Tests"); + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenObsIncidentNotFresh() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-incident", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { StellaOpsScopes.ObservabilityIncident }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15) + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-incident", ResolveProvider(clientDocument)); + principal.SetScopes(StellaOpsScopes.ObservabilityIncident); + var staleAuthTime = DateTimeOffset.UtcNow.AddMinutes(-10); + principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuthTime.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-incident" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Fact] + public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationMissingClaims() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-policy", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { StellaOpsScopes.PolicyPublish }, + CreatedAt = DateTimeOffset.UtcNow + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-policy", ResolveProvider(clientDocument)); + principal.SetScopes(StellaOpsScopes.PolicyPublish); + principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue); + principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy"); + principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2000"); + principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-policy" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Fact] + public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationNotFresh() + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-policy-stale", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { StellaOpsScopes.PolicyPublish }, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20) + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-policy-stale", ResolveProvider(clientDocument)); + principal.SetScopes(StellaOpsScopes.PolicyPublish); + principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue); + principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('a', 64)); + principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy"); + principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2001"); + var staleAuth = DateTimeOffset.UtcNow.AddMinutes(-10); + principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuth.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-policy-stale" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + } + + [Theory] + [InlineData(StellaOpsScopes.PolicyPublish, AuthorityOpenIddictConstants.PolicyOperationPublishValue)] + [InlineData(StellaOpsScopes.PolicyPromote, AuthorityOpenIddictConstants.PolicyOperationPromoteValue)] + public async Task ValidateAccessTokenHandler_AllowsPolicyAttestationWithMetadata(string scope, string expectedOperation) + { + var clientDocument = CreateClient(tenant: "tenant-alpha"); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = $"token-{expectedOperation}", + Status = "valid", + ClientId = clientDocument.ClientId, + Tenant = "tenant-alpha", + Scope = new List { scope }, + CreatedAt = DateTimeOffset.UtcNow + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + var principal = CreatePrincipal(clientDocument.ClientId, $"token-{expectedOperation}", ResolveProvider(clientDocument)); + principal.SetScopes(scope); + principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, expectedOperation); + principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('b', 64)); + principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Promotion approved"); + principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2002"); + principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = $"token-{expectedOperation}" + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + var metadata = metadataAccessor.GetMetadata(); + Assert.NotNull(metadata); + Assert.True(metadata!.Tags.TryGetValue("authority.policy_attestation_validated", out var tagValue)); + Assert.Equal(expectedOperation.ToLowerInvariant(), tagValue); + } + + [Fact] + public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope() + { + var handler = new ValidateRefreshTokenGrantHandler(NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + EndpointType = OpenIddictServerEndpointType.Token, + Options = new OpenIddictServerOptions(), + Request = new OpenIddictRequest + { + GrantType = OpenIddictConstants.GrantTypes.RefreshToken + } + }; + + var principal = CreatePrincipal("cli-app", "refresh-token", "standard"); + principal.SetScopes(StellaOpsScopes.ObservabilityIncident); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction) + { + Principal = principal + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidGrant, context.Error); + } + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenMtlsCertificateMissing() + { + var clientDocument = CreateClient(); + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-mtls-missing", + Status = "valid", + ClientId = clientDocument.ClientId, + SenderConstraint = AuthoritySenderConstraintKinds.Mtls, + SenderCertificateHex = "DEADBEEF" + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + + transaction.Properties[typeof(HttpContext).FullName!] = new DefaultHttpContext(); + + var principal = CreatePrincipal(clientDocument.ClientId, "token-mtls-missing", ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-mtls-missing" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + Assert.Equal("Sender certificate required for this token.", context.ErrorDescription); + } + + [Fact] + public async Task ValidateAccessTokenHandler_Rejects_WhenMtlsCertificateMismatch() + { + var clientDocument = CreateClient(); + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("client.mtls.test"); + request.CertificateExtensions.Add(sanBuilder.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(5)); + + var tokenStore = new TestTokenStore + { + Inserted = new AuthorityTokenDocument + { + TokenId = "token-mtls-mismatch", + Status = "valid", + ClientId = clientDocument.ClientId, + SenderConstraint = AuthoritySenderConstraintKinds.Mtls, + SenderCertificateHex = "CAFEBABE" + } + }; + + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var auditSink = new TestAuthEventSink(); + var sessionAccessor = new NullMongoSessionAccessor(); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + new TestClientStore(clientDocument), + CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)), + metadataAccessor, + auditSink, + TimeProvider.System, + TestInstruments.ActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var httpContext = new DefaultHttpContext(); + httpContext.Connection.ClientCertificate = certificate; + + var transaction = new OpenIddictServerTransaction + { + Options = new OpenIddictServerOptions(), + EndpointType = OpenIddictServerEndpointType.Token, + Request = new OpenIddictRequest() + }; + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var principal = CreatePrincipal(clientDocument.ClientId, "token-mtls-mismatch", ResolveProvider(clientDocument)); + var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) + { + Principal = principal, + TokenId = "token-mtls-mismatch" + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); + Assert.Equal("Sender certificate mismatch.", context.ErrorDescription); + } + +} + +internal static class TestInstruments +{ + public static readonly ActivitySource ActivitySource = new("StellaOps.Authority.Tests"); + public static readonly Meter Meter = new("StellaOps.Authority.Tests"); +} + +internal static class TestHelpers +{ + public static StellaOpsAuthorityOptions CreateAuthorityOptions(Action? configure = null) + { + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test") + }; + + options.Signing.ActiveKeyId = "test-key"; + options.Signing.KeyPath = "/tmp/test-key.pem"; + options.Storage.ConnectionString = "mongodb://localhost/test"; + + configure?.Invoke(options); + return options; + } + + public static AuthorityClientDocument CreateClient( + string clientId = "concelier", + string? secret = "s3cr3t!", + string clientType = "confidential", + string allowedGrantTypes = "client_credentials", + string allowedScopes = "jobs:read", + string allowedAudiences = "", + string? tenant = null) + { + var document = new AuthorityClientDocument + { + ClientId = clientId, + ClientType = clientType, + SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret), + Plugin = "standard", + Properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AuthorityClientMetadataKeys.AllowedGrantTypes] = allowedGrantTypes, + [AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes + } + }; + + if (!string.IsNullOrWhiteSpace(allowedAudiences)) + { + document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences; + } + + var normalizedTenant = NormalizeTenant(tenant); + if (normalizedTenant is not null) + { + document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; + } + + return document; + } + + private static string? NormalizeTenant(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + + public static string ResolveProvider(AuthorityClientDocument document) + => string.IsNullOrWhiteSpace(document.Plugin) ? "standard" : document.Plugin; + + private static OpenIddictRequest GetRequest(OpenIddictServerTransaction transaction) + => transaction.Request ?? throw new InvalidOperationException("OpenIddict request is required for this test."); + + public static void SetParameter(OpenIddictServerTransaction transaction, string name, object? value) + { + var parameter = value switch + { + null => default, + OpenIddictParameter existing => existing, + string s => new OpenIddictParameter(s), + bool b => new OpenIddictParameter(b), + int i => new OpenIddictParameter(i), + long l => new OpenIddictParameter(l), + _ => new OpenIddictParameter(value?.ToString()) + }; + GetRequest(transaction).SetParameter(name, parameter); + } + + public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document) + { + var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); + var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); + var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty(); + + return new AuthorityClientDescriptor( + document.ClientId, + document.DisplayName, + confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase), + allowedGrantTypes, + allowedScopes, + allowedAudiences, + redirectUris: Array.Empty(), + postLogoutRedirectUris: Array.Empty(), + properties: document.Properties); + } + + public static AuthorityIdentityProviderRegistry CreateRegistry(bool withClientProvisioning, AuthorityClientDescriptor? clientDescriptor) + { + var plugin = CreatePlugin( + name: "standard", + supportsClientProvisioning: withClientProvisioning, + descriptor: clientDescriptor, + user: null); + + return CreateRegistryFromPlugins(plugin); + } + + public static TestIdentityProviderPlugin CreatePlugin( + string name, + bool supportsClientProvisioning, + AuthorityClientDescriptor? descriptor, + AuthorityUserDescriptor? user) + { + var capabilities = supportsClientProvisioning + ? new[] { AuthorityPluginCapabilities.ClientProvisioning } + : Array.Empty(); + + var manifest = new AuthorityPluginManifest( + name, + "standard", + true, + null, + null, + capabilities, + new Dictionary(StringComparer.OrdinalIgnoreCase), + $"{name}.yaml"); + + var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); + + return new TestIdentityProviderPlugin( + context, + new TestUserCredentialStore(user), + new TestClaimsEnricher(), + supportsClientProvisioning ? new TestClientProvisioningStore(descriptor) : null, + new AuthorityIdentityProviderCapabilities( + SupportsPassword: true, + SupportsMfa: false, + SupportsClientProvisioning: supportsClientProvisioning)); + } + + public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins) + { + var services = new ServiceCollection(); + services.AddLogging(); + foreach (var plugin in plugins) + { + services.AddSingleton(plugin); + } + + var provider = services.BuildServiceProvider(); + return new AuthorityIdentityProviderRegistry(provider, NullLogger.Instance); + } + + public static OpenIddictServerTransaction CreateTokenTransaction(string clientId, string? secret, string? scope) + { + var request = new OpenIddictRequest + { + GrantType = OpenIddictConstants.GrantTypes.ClientCredentials, + ClientId = clientId, + ClientSecret = secret + }; + + if (!string.IsNullOrWhiteSpace(scope)) + { + request.Scope = scope; + } + + return new OpenIddictServerTransaction + { + EndpointType = OpenIddictServerEndpointType.Token, + Options = new OpenIddictServerOptions(), + Request = request + }; + } + + public static string ConvertThumbprintToString(object thumbprint) + => thumbprint switch + { + string value => value, + byte[] bytes => Base64UrlEncoder.Encode(bytes), + _ => throw new InvalidOperationException("Unsupported thumbprint representation.") + }; + + public static string CreateDpopProof(ECDsaSecurityKey key, string method, string url, long issuedAt, string? nonce = null) + { + var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key); + jwk.KeyId ??= key.KeyId ?? Guid.NewGuid().ToString("N"); + + var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); + var header = new JwtHeader(signingCredentials) + { + ["typ"] = "dpop+jwt", + ["jwk"] = new Dictionary + { + ["kty"] = jwk.Kty, + ["crv"] = jwk.Crv, + ["x"] = jwk.X, + ["y"] = jwk.Y, + ["kid"] = jwk.Kid ?? jwk.KeyId + } + }; + + var payload = new JwtPayload + { + ["htm"] = method.ToUpperInvariant(), + ["htu"] = url, + ["iat"] = issuedAt, + ["jti"] = Guid.NewGuid().ToString("N") + }; + + if (!string.IsNullOrWhiteSpace(nonce)) + { + payload["nonce"] = nonce; + } + + var token = new JwtSecurityToken(header, payload); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static X509Certificate2 CreateTestCertificate(string subjectName) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + } + + public static ClaimsPrincipal CreatePrincipal(string clientId, string tokenId, string provider, string? subject = null) + { + var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId)); + identity.AddClaim(new Claim(OpenIddictConstants.Claims.JwtId, tokenId)); + identity.AddClaim(new Claim(StellaOpsClaimTypes.IdentityProvider, provider)); + identity.AddClaim(new Claim(StellaOpsClaimTypes.Project, StellaOpsTenancyDefaults.AnyProject)); + + if (!string.IsNullOrWhiteSpace(subject)) + { + identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, subject)); + } + + return new ClaimsPrincipal(identity); + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs index eed98b29a..3ca4c87d4 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs @@ -177,16 +177,17 @@ public sealed class TokenPersistenceIntegrationTests var auditSink = new TestAuthEventSink(); await using var scope = provider.CreateAsyncScope(); var sessionAccessor = scope.ServiceProvider.GetRequiredService(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - clientStore, - registry, - metadataAccessor, - auditSink, - clock, - TestActivitySource, - NullLogger.Instance); + var handler = new ValidateAccessTokenHandler( + tokenStore, + sessionAccessor, + clientStore, + registry, + metadataAccessor, + auditSink, + clock, + TestActivitySource, + TestInstruments.Meter, + NullLogger.Instance); var transaction = new OpenIddictServerTransaction { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs index 7381b7edc..d4e2cb185 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs @@ -30,8 +30,10 @@ public sealed class AuthorityJwksServiceTests var registry = new TestRegistry(provider); using var cache = new MemoryCache(new MemoryCacheOptions()); var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-30T12:00:00Z")); + var hash = CryptoHashFactory.CreateDefault(); var service = new AuthorityJwksService( registry, + hash, NullLogger.Instance, cache, clock, @@ -64,8 +66,10 @@ public sealed class AuthorityJwksServiceTests var registry = new TestRegistry(provider); using var cache = new MemoryCache(new MemoryCacheOptions()); var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-30T12:00:00Z")); + var hash = CryptoHashFactory.CreateDefault(); var service = new AuthorityJwksService( registry, + hash, NullLogger.Instance, cache, clock, diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/KmsAuthoritySigningKeySourceTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/KmsAuthoritySigningKeySourceTests.cs index 5a2b0c3c1..550966e6f 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/KmsAuthoritySigningKeySourceTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/KmsAuthoritySigningKeySourceTests.cs @@ -48,7 +48,7 @@ public sealed class KmsAuthoritySigningKeySourceTests var signingKey = source.Load(request); Assert.Equal(CryptoSigningKeyKind.Raw, signingKey.Kind); - Assert.Equal(material.KeyId, signingKey.Reference.KeyId); + Assert.Equal(request.KeyId, signingKey.Reference.KeyId); Assert.True(signingKey.PrivateKey.Length > 0); Assert.True(signingKey.PublicKey.Length > 0); Assert.Equal(material.VersionId, signingKey.Metadata[KmsAuthoritySigningKeySource.KmsMetadataKeys.Version]); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleEndpointExtensions.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleEndpointExtensions.cs index dab43f90c..76812775d 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleEndpointExtensions.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleEndpointExtensions.cs @@ -37,10 +37,41 @@ internal static class ConsoleEndpointExtensions .WithName("ConsoleProfile") .WithSummary("Return the authenticated principal profile metadata."); - group.MapPost("/token/introspect", IntrospectToken) - .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead)) - .WithName("ConsoleTokenIntrospect") - .WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata."); + group.MapPost("/token/introspect", IntrospectToken) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead)) + .WithName("ConsoleTokenIntrospect") + .WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata."); + + var vulnGroup = group.MapGroup("/vuln") + .RequireAuthorization(policy => policy.RequireStellaOpsScopes( + StellaOpsScopes.UiRead, + StellaOpsScopes.AdvisoryRead, + StellaOpsScopes.VexRead)); + + vulnGroup.MapGet("/findings", GetVulnerabilityFindings) + .WithName("ConsoleVulnerabilityFindings") + .WithSummary("List tenant-scoped vulnerability findings with policy/VEX metadata."); + + vulnGroup.MapGet("/{findingId}", GetVulnerabilityFindingById) + .WithName("ConsoleVulnerabilityFindingDetail") + .WithSummary("Return the full finding document, including evidence and policy overlays."); + + vulnGroup.MapPost("/tickets", CreateVulnerabilityTicket) + .WithName("ConsoleVulnerabilityTickets") + .WithSummary("Generate a signed payload payload for external ticketing workflows."); + + var vexGroup = group.MapGroup("/vex") + .RequireAuthorization(policy => policy.RequireStellaOpsScopes( + StellaOpsScopes.UiRead, + StellaOpsScopes.VexRead)); + + vexGroup.MapGet("/statements", GetVexStatements) + .WithName("ConsoleVexStatements") + .WithSummary("List VEX statements impacting the tenant."); + + vexGroup.MapGet("/events", StreamVexEvents) + .WithName("ConsoleVexEvents") + .WithSummary("Server-sent events feed for live VEX updates (placeholder)."); } private static async Task GetTenants( @@ -134,11 +165,11 @@ internal static class ConsoleEndpointExtensions return Results.Ok(profile); } - private static async Task IntrospectToken( - HttpContext httpContext, - TimeProvider timeProvider, - IAuthEventSink auditSink, - CancellationToken cancellationToken) + private static async Task IntrospectToken( + HttpContext httpContext, + TimeProvider timeProvider, + IAuthEventSink auditSink, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(httpContext); ArgumentNullException.ThrowIfNull(timeProvider); @@ -152,21 +183,214 @@ internal static class ConsoleEndpointExtensions var introspection = BuildTokenIntrospection(principal, timeProvider); - await WriteAuditAsync( - httpContext, - auditSink, - timeProvider, - "authority.console.token.introspect", - AuthEventOutcome.Success, - null, - BuildProperties( - ("token.active", introspection.Active ? "true" : "false"), - ("token.expires_at", FormatInstant(introspection.ExpiresAt)), - ("tenant.resolved", introspection.Tenant)), - cancellationToken).ConfigureAwait(false); - - return Results.Ok(introspection); - } + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.token.introspect", + AuthEventOutcome.Success, + null, + BuildProperties( + ("token.active", introspection.Active ? "true" : "false"), + ("token.expires_at", FormatInstant(introspection.ExpiresAt)), + ("tenant.resolved", introspection.Tenant)), + cancellationToken).ConfigureAwait(false); + + return Results.Ok(introspection); + } + + private static async Task GetVulnerabilityFindings( + HttpContext httpContext, + IConsoleWorkspaceService workspaceService, + TimeProvider timeProvider, + IAuthEventSink auditSink, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(workspaceService); + + var tenant = TenantHeaderFilter.GetTenant(httpContext); + if (string.IsNullOrWhiteSpace(tenant)) + { + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vuln.findings", + AuthEventOutcome.Failure, + "tenant_header_missing", + BuildProperties(("tenant.header", null)), + cancellationToken).ConfigureAwait(false); + + return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." }); + } + + var query = BuildVulnerabilityQuery(httpContext.Request); + var response = await workspaceService.SearchFindingsAsync(tenant, query, cancellationToken).ConfigureAwait(false); + + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vuln.findings", + AuthEventOutcome.Success, + null, + BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)), + cancellationToken).ConfigureAwait(false); + + return Results.Ok(response); + } + + private static async Task GetVulnerabilityFindingById( + HttpContext httpContext, + string findingId, + IConsoleWorkspaceService workspaceService, + TimeProvider timeProvider, + IAuthEventSink auditSink, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(workspaceService); + + var tenant = TenantHeaderFilter.GetTenant(httpContext); + if (string.IsNullOrWhiteSpace(tenant)) + { + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vuln.finding", + AuthEventOutcome.Failure, + "tenant_header_missing", + BuildProperties(("tenant.header", null)), + cancellationToken).ConfigureAwait(false); + + return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." }); + } + + var detail = await workspaceService.GetFindingAsync(tenant, findingId, cancellationToken).ConfigureAwait(false); + if (detail is null) + { + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vuln.finding", + AuthEventOutcome.Failure, + "finding_not_found", + BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)), + cancellationToken).ConfigureAwait(false); + + return Results.NotFound(new { error = "finding_not_found", message = $"Finding '{findingId}' not found." }); + } + + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vuln.finding", + AuthEventOutcome.Success, + null, + BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)), + cancellationToken).ConfigureAwait(false); + + return Results.Ok(detail); + } + + private static async Task CreateVulnerabilityTicket( + HttpContext httpContext, + ConsoleVulnerabilityTicketRequest request, + IConsoleWorkspaceService workspaceService, + TimeProvider timeProvider, + IAuthEventSink auditSink, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(workspaceService); + + if (request is null || request.Selection.Count == 0) + { + return Results.BadRequest(new { error = "invalid_request", message = "At least one finding must be selected." }); + } + + var tenant = TenantHeaderFilter.GetTenant(httpContext); + if (string.IsNullOrWhiteSpace(tenant)) + { + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vuln.ticket", + AuthEventOutcome.Failure, + "tenant_header_missing", + BuildProperties(("tenant.header", null)), + cancellationToken).ConfigureAwait(false); + + return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." }); + } + + var ticket = await workspaceService.CreateTicketAsync(tenant, request, cancellationToken).ConfigureAwait(false); + + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vuln.ticket", + AuthEventOutcome.Success, + null, + BuildProperties( + ("tenant.resolved", tenant), + ("ticket.id", ticket.TicketId), + ("ticket.selection.count", request.Selection.Count.ToString(CultureInfo.InvariantCulture))), + cancellationToken).ConfigureAwait(false); + + return Results.Ok(ticket); + } + + private static async Task GetVexStatements( + HttpContext httpContext, + IConsoleWorkspaceService workspaceService, + TimeProvider timeProvider, + IAuthEventSink auditSink, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(workspaceService); + + var tenant = TenantHeaderFilter.GetTenant(httpContext); + if (string.IsNullOrWhiteSpace(tenant)) + { + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vex.statements", + AuthEventOutcome.Failure, + "tenant_header_missing", + BuildProperties(("tenant.header", null)), + cancellationToken).ConfigureAwait(false); + + return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." }); + } + + var query = BuildVexQuery(httpContext.Request); + var response = await workspaceService.GetVexStatementsAsync(tenant, query, cancellationToken).ConfigureAwait(false); + + await WriteAuditAsync( + httpContext, + auditSink, + timeProvider, + "authority.console.vex.statements", + AuthEventOutcome.Success, + null, + BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)), + cancellationToken).ConfigureAwait(false); + + return Results.Ok(response); + } + + private static IResult StreamVexEvents() => + Results.StatusCode(StatusCodes.Status501NotImplemented); private static ConsoleProfileResponse BuildProfile(ClaimsPrincipal principal, TimeProvider timeProvider) { @@ -231,9 +455,9 @@ internal static class ConsoleEndpointExtensions FreshAuth: freshAuth); } - private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now) - { - var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth"); + private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now) + { + var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth"); if (flag is not null && bool.TryParse(flag.Value, out var freshFlag)) { if (freshFlag) @@ -254,9 +478,67 @@ internal static class ConsoleEndpointExtensions return authTime.Value.Add(ttl) > now; } - const int defaultFreshAuthWindowSeconds = 300; - return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now; - } + const int defaultFreshAuthWindowSeconds = 300; + return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now; + } + + private static ConsoleVulnerabilityQuery BuildVulnerabilityQuery(HttpRequest request) + { + var builder = new ConsoleVulnerabilityQueryBuilder() + .SetPageSize(ParseInt(request.Query["pageSize"], 50)) + .SetPageToken(request.Query.TryGetValue("pageToken", out var tokenValues) ? tokenValues.FirstOrDefault() : null) + .AddSeverity(ReadMulti(request, "severity")) + .AddPolicyBadges(ReadMulti(request, "policyBadge")) + .AddReachability(ReadMulti(request, "reachability")) + .AddProducts(ReadMulti(request, "product")) + .AddVexStates(ReadMulti(request, "vexState")); + + var search = request.Query.TryGetValue("search", out var searchValues) + ? searchValues + .Where(value => !string.IsNullOrWhiteSpace(value)) + .SelectMany(value => value!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + : Array.Empty(); + + builder.AddSearchTerms(search); + return builder.Build(); + } + + private static ConsoleVexQuery BuildVexQuery(HttpRequest request) + { + var builder = new ConsoleVexQueryBuilder() + .SetPageSize(ParseInt(request.Query["pageSize"], 50)) + .SetPageToken(request.Query.TryGetValue("pageToken", out var pageValues) ? pageValues.FirstOrDefault() : null) + .AddAdvisories(ReadMulti(request, "advisoryId")) + .AddTypes(ReadMulti(request, "statementType")) + .AddStates(ReadMulti(request, "state")); + + return builder.Build(); + } + + private static IEnumerable ReadMulti(HttpRequest request, string key) + { + if (!request.Query.TryGetValue(key, out var values)) + { + return Array.Empty(); + } + + return values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .SelectMany(value => value!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Where(value => value.Length > 0); + } + + private static int ParseInt(StringValues values, int fallback) + { + if (values.Count == 0) + { + return fallback; + } + + return int.TryParse(values[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) + ? number + : fallback; + } private static IReadOnlyList ExtractRoles(ClaimsPrincipal principal) { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceModels.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceModels.cs new file mode 100644 index 000000000..9029e9648 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceModels.cs @@ -0,0 +1,304 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace StellaOps.Authority.Console; + +internal sealed record ConsoleFacetBucket(string Value, int Count); + +internal sealed record ConsoleFacetDistribution( + IReadOnlyList Severity, + IReadOnlyList PolicyBadge, + IReadOnlyList Reachability); + +internal sealed record ConsoleFindingTimestamps( + DateTimeOffset FirstSeen, + DateTimeOffset LastSeen, + DateTimeOffset? VexLastUpdated); + +internal sealed record ConsoleVulnCoordinates( + string AdvisoryId, + string Package, + string Component, + string Image); + +internal sealed record ConsoleVulnVexSummary( + string StatementId, + string State, + string? Justification); + +internal sealed record ConsoleReachabilitySummary( + string Status, + DateTimeOffset? LastObserved, + string? SignalsVersion); + +internal sealed record ConsoleReachabilityDetail( + string Status, + IReadOnlyList CallPathSamples, + DateTimeOffset? LastObserved, + string? SignalsVersion); + +internal sealed record ConsoleVulnEvidenceSummary( + string? SbomDigest, + string? PolicyRunId, + string? AttestationId); + +internal sealed record ConsoleEvidenceDetail( + ConsoleVulnEvidenceSummary Summary, + IReadOnlyList ComponentPath, + IReadOnlyList Attestations); + +internal sealed record ConsoleAttestationReference( + string Type, + string AttestationId, + string Signer, + string BundleDigest); + +internal sealed record ConsolePolicyBadge( + string PolicyId, + string Verdict, + string? ExplainUrl); + +internal sealed record ConsoleVulnVexDetail( + string StatementId, + string State, + string Justification, + string? ImpactStatement, + IReadOnlyList Remediations); + +internal sealed record ConsoleRemediation( + string Type, + string Description, + DateTimeOffset? Deadline); + +internal sealed record ConsoleVulnerabilityFinding( + string Tenant, + string FindingId, + ConsoleVulnCoordinates Coordinates, + string Summary, + string Severity, + double? Cvss, + bool Kev, + string PolicyBadge, + ConsoleVulnVexSummary? Vex, + ConsoleReachabilitySummary? Reachability, + ConsoleVulnEvidenceSummary? Evidence, + ConsoleFindingTimestamps Timestamps); + +internal sealed record ConsoleVulnerabilityFindingDetail( + ConsoleVulnerabilityFinding Summary, + string Description, + IReadOnlyList References, + IReadOnlyList PolicyBadges, + ConsoleVulnVexDetail? Vex, + ConsoleReachabilityDetail? Reachability, + ConsoleEvidenceDetail Evidence); + +internal sealed record ConsoleVulnerabilitySearchResponse( + IReadOnlyList Items, + ConsoleFacetDistribution Facets, + string? NextPageToken); + +internal sealed record ConsoleVulnerabilityQuery( + IReadOnlyList Severity, + IReadOnlyList PolicyBadges, + IReadOnlyList Reachability, + IReadOnlyList Products, + IReadOnlyList VexStates, + IReadOnlyList SearchTerms, + int PageSize, + string? PageToken); + +internal sealed record ConsoleVulnerabilityTicketRequest( + IReadOnlyList Selection, + string TargetSystem, + IReadOnlyDictionary? Metadata); + +internal sealed record ConsoleTicketPayload( + string Version, + string Tenant, + IReadOnlyList Findings, + string PolicyBadge, + string VexSummary, + IReadOnlyList Attachments); + +internal sealed record ConsoleTicketSelection(string FindingId, string Severity); + +internal sealed record ConsoleTicketAttachment( + string Type, + string Name, + string Digest, + string ContentType, + DateTimeOffset ExpiresAt); + +internal sealed record ConsoleVulnerabilityTicketResponse( + string TicketId, + ConsoleTicketPayload Payload, + string AuditEventId); + +internal sealed record ConsoleVexStatementSummary( + string Tenant, + string StatementId, + string AdvisoryId, + string Product, + string State, + string Justification, + string StatementType, + DateTimeOffset LastUpdated, + ConsoleVexSourceMetadata Source); + +internal sealed record ConsoleVexSourceMetadata( + string Type, + string? ModelBuild, + double? Confidence); + +internal sealed record ConsoleVexStatementPage( + IReadOnlyList Items, + string? NextPageToken); + +internal sealed record ConsoleVexQuery( + IReadOnlyList AdvisoryIds, + IReadOnlyList StatementTypes, + IReadOnlyList States, + int PageSize, + string? PageToken); + +internal interface IConsoleWorkspaceService +{ + Task SearchFindingsAsync( + string tenant, + ConsoleVulnerabilityQuery query, + CancellationToken cancellationToken); + + Task GetFindingAsync( + string tenant, + string findingId, + CancellationToken cancellationToken); + + Task CreateTicketAsync( + string tenant, + ConsoleVulnerabilityTicketRequest request, + CancellationToken cancellationToken); + + Task GetVexStatementsAsync( + string tenant, + ConsoleVexQuery query, + CancellationToken cancellationToken); +} + +internal sealed class ConsoleVulnerabilityQueryBuilder +{ + private readonly HashSet _severity = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _policy = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _reachability = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _products = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _vexStates = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _searchTerms = new(StringComparer.OrdinalIgnoreCase); + private int _pageSize = 50; + private string? _pageToken; + + public ConsoleVulnerabilityQueryBuilder SetPageSize(int value) + { + _pageSize = Math.Clamp(value, 1, 200); + return this; + } + + public ConsoleVulnerabilityQueryBuilder SetPageToken(string? token) + { + _pageToken = string.IsNullOrWhiteSpace(token) ? null : token; + return this; + } + + public ConsoleVulnerabilityQueryBuilder AddSeverity(IEnumerable values) + { + _severity.UnionWith(values); + return this; + } + + public ConsoleVulnerabilityQueryBuilder AddPolicyBadges(IEnumerable values) + { + _policy.UnionWith(values); + return this; + } + + public ConsoleVulnerabilityQueryBuilder AddReachability(IEnumerable values) + { + _reachability.UnionWith(values); + return this; + } + + public ConsoleVulnerabilityQueryBuilder AddProducts(IEnumerable values) + { + _products.UnionWith(values); + return this; + } + + public ConsoleVulnerabilityQueryBuilder AddVexStates(IEnumerable values) + { + _vexStates.UnionWith(values); + return this; + } + + public ConsoleVulnerabilityQueryBuilder AddSearchTerms(IEnumerable values) + { + _searchTerms.UnionWith(values); + return this; + } + + public ConsoleVulnerabilityQuery Build() => + new( + _severity.ToImmutableArray(), + _policy.ToImmutableArray(), + _reachability.ToImmutableArray(), + _products.ToImmutableArray(), + _vexStates.ToImmutableArray(), + _searchTerms.ToImmutableArray(), + _pageSize, + _pageToken); +} + +internal sealed class ConsoleVexQueryBuilder +{ + private readonly HashSet _advisories = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _types = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _states = new(StringComparer.OrdinalIgnoreCase); + private int _pageSize = 50; + private string? _pageToken; + + public ConsoleVexQueryBuilder SetPageSize(int value) + { + _pageSize = Math.Clamp(value, 1, 200); + return this; + } + + public ConsoleVexQueryBuilder SetPageToken(string? token) + { + _pageToken = string.IsNullOrWhiteSpace(token) ? null : token; + return this; + } + + public ConsoleVexQueryBuilder AddAdvisories(IEnumerable values) + { + _advisories.UnionWith(values); + return this; + } + + public ConsoleVexQueryBuilder AddTypes(IEnumerable values) + { + _types.UnionWith(values); + return this; + } + + public ConsoleVexQueryBuilder AddStates(IEnumerable values) + { + _states.UnionWith(values); + return this; + } + + public ConsoleVexQuery Build() => + new( + _advisories.ToImmutableArray(), + _types.ToImmutableArray(), + _states.ToImmutableArray(), + _pageSize, + _pageToken); +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceSampleService.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceSampleService.cs new file mode 100644 index 000000000..d51d6d7dc --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleWorkspaceSampleService.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Authority.Console; + +internal sealed class ConsoleWorkspaceSampleService : IConsoleWorkspaceService +{ + private static readonly ImmutableArray SampleFindings; + private static readonly ImmutableArray SampleStatements; + + static ConsoleWorkspaceSampleService() + { + var finding1Summary = new ConsoleVulnerabilityFinding( + Tenant: "tenant-default", + FindingId: "tenant-default:advisory-ai:sha256:5d1a", + Coordinates: new ConsoleVulnCoordinates( + AdvisoryId: "CVE-2024-12345", + Package: "pkg:npm/jsonwebtoken@9.0.2", + Component: "jwt-auth-service", + Image: "registry.local/ops/auth:2025.10.0"), + Summary: "jsonwebtoken <10.0.0 allows algorithm downgrade.", + Severity: "high", + Cvss: 8.1, + Kev: true, + PolicyBadge: "fail", + Vex: new ConsoleVulnVexSummary( + StatementId: "vex:tenant-default:jwt-auth:5d1a", + State: "under_investigation", + Justification: "Advisory AI flagged reachable path via Scheduler run 42."), + Reachability: new ConsoleReachabilitySummary( + Status: "reachable", + LastObserved: DateTimeOffset.Parse("2025-11-07T23:11:04Z"), + SignalsVersion: "signals-2025.310.1"), + Evidence: new ConsoleVulnEvidenceSummary( + SbomDigest: "sha256:6c81f2bbd8bd7336f197f3f68fba2f76d7287dd1a5e2a0f0e9f14f23f3c2f917", + PolicyRunId: "policy-run::2025-11-07::ca9f", + AttestationId: "dsse://authority/attest/84a2"), + Timestamps: new ConsoleFindingTimestamps( + FirstSeen: DateTimeOffset.Parse("2025-10-31T04:22:18Z"), + LastSeen: DateTimeOffset.Parse("2025-11-07T23:16:51Z"), + VexLastUpdated: DateTimeOffset.Parse("2025-11-07T23:10:09Z"))); + + var finding2Summary = new ConsoleVulnerabilityFinding( + Tenant: "tenant-default", + FindingId: "tenant-default:advisory-ai:sha256:9bf4", + Coordinates: new ConsoleVulnCoordinates( + AdvisoryId: "GHSA-xxxx-yyyy-zzzz", + Package: "pkg:docker/library/nginx@1.25.2", + Component: "ingress-gateway", + Image: "registry.local/ops/ingress:2025.09.1"), + Summary: "Heap overflow in nginx HTTP/3 parsing.", + Severity: "critical", + Cvss: 9.8, + Kev: false, + PolicyBadge: "warn", + Vex: new ConsoleVulnVexSummary( + StatementId: "vex:tenant-default:ingress:9bf4", + State: "not_affected", + Justification: "component_not_present"), + Reachability: new ConsoleReachabilitySummary( + Status: "unknown", + LastObserved: null, + SignalsVersion: "signals-2025.309.0"), + Evidence: new ConsoleVulnEvidenceSummary( + SbomDigest: "sha256:99f1e2a7aa0f7c970dcb6674244f0bfb5f37148e3ee09fd4f925d3358dea2239", + PolicyRunId: "policy-run::2025-11-06::b210", + AttestationId: "dsse://authority/attest/1d34"), + Timestamps: new ConsoleFindingTimestamps( + FirstSeen: DateTimeOffset.Parse("2025-10-29T18:03:11Z"), + LastSeen: DateTimeOffset.Parse("2025-11-07T10:45:03Z"), + VexLastUpdated: DateTimeOffset.Parse("2025-11-06T18:44:00Z"))); + + SampleFindings = ImmutableArray.Create( + new ConsoleVulnerabilityFindingDetail( + Summary: finding1Summary, + Description: "jsonwebtoken accepts untrusted algorithm overrides which allow downgrade attacks.", + References: new[] + { + "https://nvd.nist.gov/vuln/detail/CVE-2024-12345", + "https://github.com/auth0/node-jsonwebtoken/security/advisories/GHSA-45mw-4jw3-g2wg" + }, + PolicyBadges: new[] + { + new ConsolePolicyBadge("policy://tenant-default/runtime-hardening", "fail", "https://console.local/policy/runs/policy-run::2025-11-07::ca9f") + }, + Vex: new ConsoleVulnVexDetail( + StatementId: "vex:tenant-default:jwt-auth:5d1a", + State: "under_investigation", + Justification: "Runtime telemetry confirmed exploitation path.", + ImpactStatement: "Token exchange service remains exposed until patch 2025.11.2.", + Remediations: new[] + { + new ConsoleRemediation("patch", "Upgrade jwt-auth-service to 2025.11.2.", DateTimeOffset.Parse("2025-11-12T00:00:00Z")) + }), + Reachability: new ConsoleReachabilityDetail( + Status: "reachable", + CallPathSamples: new[] + { + "api-gateway -> jwt-auth-service -> jsonwebtoken.verify" + }, + LastObserved: DateTimeOffset.Parse("2025-11-07T23:11:04Z"), + SignalsVersion: "signals-2025.310.1"), + Evidence: new ConsoleEvidenceDetail( + Summary: finding1Summary.Evidence!, + ComponentPath: new[] + { + "/src/jwt-auth/package.json", + "/src/jwt-auth/node_modules/jsonwebtoken" + }, + Attestations: new[] + { + new ConsoleAttestationReference("scan-report", "dsse://authority/attest/84a2", "attestor@stella-ops.org", "sha256:e2bb5c7a0a8b2d16ff42e7f8decb4bb8be71ad0a1606dbc5d28be43675fbad32") + })), + new ConsoleVulnerabilityFindingDetail( + Summary: finding2Summary, + Description: "nginx HTTP/3 heap overflow affecting unpatched ingress nodes.", + References: new[] + { + "https://security.nginx.org/announcements/2024/http3-overflow", + }, + PolicyBadges: new[] + { + new ConsolePolicyBadge("policy://tenant-default/network-hardening", "warn", null) + }, + Vex: new ConsoleVulnVexDetail( + StatementId: "vex:tenant-default:ingress:9bf4", + State: "not_affected", + Justification: "component_not_present", + ImpactStatement: "HTTP/3 disabled on ingress, exposure limited.", + Remediations: Array.Empty()), + Reachability: new ConsoleReachabilityDetail( + Status: "unknown", + CallPathSamples: Array.Empty(), + LastObserved: null, + SignalsVersion: "signals-2025.309.0"), + Evidence: new ConsoleEvidenceDetail( + Summary: finding2Summary.Evidence!, + ComponentPath: new[] + { + "/charts/ingress/templates/deployment.yaml" + }, + Attestations: new[] + { + new ConsoleAttestationReference("scan-report", "dsse://authority/attest/1d34", "attestor@stella-ops.org", "sha256:91e6dd2c1bbf9a4ac797e050d71bf7f1b958d1a0c27469364c44d8ed74bcb9dc") + }))); + + SampleStatements = ImmutableArray.Create( + new ConsoleVexStatementSummary( + Tenant: "tenant-default", + StatementId: "vex:tenant-default:jwt-auth:5d1a", + AdvisoryId: "CVE-2024-12345", + Product: "registry.local/ops/auth:2025.10.0", + State: "under_investigation", + Justification: "exploit_observed", + StatementType: "advisory_ai", + LastUpdated: DateTimeOffset.Parse("2025-11-07T23:10:09Z"), + Source: new ConsoleVexSourceMetadata("advisory_ai", "aiai-console-2025-10-28", 0.74)), + new ConsoleVexStatementSummary( + Tenant: "tenant-default", + StatementId: "vex:tenant-default:jwt-auth:5d1a", + AdvisoryId: "CVE-2024-12345", + Product: "registry.local/ops/auth:2025.10.0", + State: "fixed", + Justification: "solution_available", + StatementType: "advisory_ai", + LastUpdated: DateTimeOffset.Parse("2025-11-08T11:44:32Z"), + Source: new ConsoleVexSourceMetadata("advisory_ai", "aiai-console-2025-10-28", 0.92)), + new ConsoleVexStatementSummary( + Tenant: "tenant-default", + StatementId: "vex:tenant-default:ingress:9bf4", + AdvisoryId: "GHSA-xxxx-yyyy-zzzz", + Product: "registry.local/ops/ingress:2025.09.1", + State: "not_affected", + Justification: "component_not_present", + StatementType: "excitor", + LastUpdated: DateTimeOffset.Parse("2025-11-06T18:44:00Z"), + Source: new ConsoleVexSourceMetadata("excitor", null, null))); + } + + public Task SearchFindingsAsync( + string tenant, + ConsoleVulnerabilityQuery query, + CancellationToken cancellationToken) + { + var filtered = SampleFindings + .Where(detail => IsTenantMatch(tenant, detail.Summary)) + .Where(detail => MatchesSeverity(detail, query)) + .Where(detail => MatchesPolicy(detail, query)) + .Where(detail => MatchesReachability(detail, query)) + .Where(detail => MatchesSearch(detail, query)) + .Take(query.PageSize) + .Select(detail => detail.Summary) + .ToImmutableArray(); + + var facets = BuildFacets(tenant); + var response = new ConsoleVulnerabilitySearchResponse(filtered, facets, NextPageToken: null); + return Task.FromResult(response); + } + + public Task GetFindingAsync( + string tenant, + string findingId, + CancellationToken cancellationToken) + { + var detail = SampleFindings.FirstOrDefault(f => + string.Equals(f.Summary.FindingId, findingId, StringComparison.OrdinalIgnoreCase) && + IsTenantMatch(tenant, f.Summary)); + + return Task.FromResult(detail); + } + + public Task CreateTicketAsync( + string tenant, + ConsoleVulnerabilityTicketRequest request, + CancellationToken cancellationToken) + { + ImmutableArray selection; + if (string.IsNullOrWhiteSpace(tenant)) + { + selection = ImmutableArray.Empty; + } + else + { + selection = request.Selection + .Select(id => SampleFindings.FirstOrDefault(f => string.Equals(f.Summary.FindingId, id, StringComparison.OrdinalIgnoreCase))) + .Where(detail => detail is not null) + .Select(detail => new ConsoleTicketSelection(detail!.Summary.FindingId, detail.Summary.Severity)) + .ToImmutableArray(); + } + + var ticketId = BuildTicketId(tenant, request.Selection); + var attachmentName = $"console-ticket-{DateTimeOffset.UtcNow:yyyyMMdd}.json"; + var payload = new ConsoleTicketPayload( + Version: "2025-11-01", + Tenant: tenant, + Findings: selection, + PolicyBadge: selection.Any(sel => string.Equals(sel.Severity, "critical", StringComparison.OrdinalIgnoreCase)) ? "fail" : "warn", + VexSummary: $"{selection.Length} findings included in ticket.", + Attachments: new[] + { + new ConsoleTicketAttachment( + Type: "json", + Name: attachmentName, + Digest: HashAttachmentName(attachmentName), + ContentType: "application/json", + ExpiresAt: DateTimeOffset.UtcNow.AddDays(7)) + }); + + var response = new ConsoleVulnerabilityTicketResponse( + TicketId: ticketId, + Payload: payload, + AuditEventId: $"{ticketId}::audit"); + + return Task.FromResult(response); + } + + public Task GetVexStatementsAsync( + string tenant, + ConsoleVexQuery query, + CancellationToken cancellationToken) + { + var filtered = SampleStatements + .Where(statement => string.Equals(statement.Tenant, tenant, StringComparison.OrdinalIgnoreCase)) + .Where(statement => MatchesAdvisory(statement, query)) + .Where(statement => MatchesState(statement, query)) + .Where(statement => MatchesType(statement, query)) + .Take(query.PageSize) + .ToImmutableArray(); + + var page = new ConsoleVexStatementPage(filtered, NextPageToken: null); + return Task.FromResult(page); + } + + private static bool MatchesSeverity(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) => + query.Severity.Count == 0 || + query.Severity.Any(sev => string.Equals(sev, detail.Summary.Severity, StringComparison.OrdinalIgnoreCase)); + + private static bool MatchesPolicy(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) => + query.PolicyBadges.Count == 0 || + query.PolicyBadges.Any(badge => string.Equals(badge, detail.Summary.PolicyBadge, StringComparison.OrdinalIgnoreCase)); + + private static bool MatchesReachability(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) + { + if (query.Reachability.Count == 0 || detail.Summary.Reachability is null) + { + return query.Reachability.Count == 0; + } + + return query.Reachability.Any(state => + string.Equals(state, detail.Summary.Reachability.Status, StringComparison.OrdinalIgnoreCase)); + } + + private static bool MatchesSearch(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) + { + if (query.SearchTerms.Count == 0) + { + return true; + } + + return query.SearchTerms.Any(term => + Contains(term, detail.Summary.FindingId) || + Contains(term, detail.Summary.Coordinates.AdvisoryId) || + Contains(term, detail.Summary.Coordinates.Component)); + } + + private static bool MatchesAdvisory(ConsoleVexStatementSummary summary, ConsoleVexQuery query) => + query.AdvisoryIds.Count == 0 || + query.AdvisoryIds.Any(advisory => string.Equals(advisory, summary.AdvisoryId, StringComparison.OrdinalIgnoreCase)); + + private static bool MatchesType(ConsoleVexStatementSummary summary, ConsoleVexQuery query) => + query.StatementTypes.Count == 0 || + query.StatementTypes.Any(type => string.Equals(type, summary.StatementType, StringComparison.OrdinalIgnoreCase)); + + private static bool MatchesState(ConsoleVexStatementSummary summary, ConsoleVexQuery query) => + query.States.Count == 0 || + query.States.Any(state => string.Equals(state, summary.State, StringComparison.OrdinalIgnoreCase)); + + private static bool Contains(string term, string value) => + value?.IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0; + + private static bool IsTenantMatch(string tenant, ConsoleVulnerabilityFinding summary) => + string.Equals(summary.Tenant, tenant, StringComparison.OrdinalIgnoreCase); + + private static ConsoleFacetDistribution BuildFacets(string tenant) + { + var findings = SampleFindings.Where(detail => IsTenantMatch(tenant, detail.Summary)).Select(detail => detail.Summary); + return new ConsoleFacetDistribution( + Severity: AggregateFacet(findings, finding => finding.Severity), + PolicyBadge: AggregateFacet(findings, finding => finding.PolicyBadge), + Reachability: AggregateFacet(findings, finding => finding.Reachability?.Status ?? "unknown")); + } + + private static IReadOnlyList AggregateFacet( + IEnumerable findings, + Func selector) => + findings + .GroupBy(selector, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(group => group.Count()) + .ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => new ConsoleFacetBucket(group.Key ?? "unknown", group.Count())) + .ToImmutableArray(); + + private static string BuildTicketId(string tenant, IEnumerable selection) + { + using var sha256 = SHA256.Create(); + var joined = string.Join("|", selection.Order()); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(joined)); + var prefix = Convert.ToHexString(hash[..8]).ToLowerInvariant(); + return $"console-ticket::{tenant}::{prefix}"; + } + + private static string HashAttachmentName(string name) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(name)); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenApi/AuthorityOpenApiDocumentProvider.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenApi/AuthorityOpenApiDocumentProvider.cs index 3a95832ed..28fde9870 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenApi/AuthorityOpenApiDocumentProvider.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenApi/AuthorityOpenApiDocumentProvider.cs @@ -1,314 +1,319 @@ -using System.Collections.Generic; -using System.IO; -using System.Globalization; -using System.Linq; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using YamlDotNet.Core; -using YamlDotNet.RepresentationModel; -using YamlDotNet.Serialization; - -namespace StellaOps.Authority.OpenApi; - -internal sealed class AuthorityOpenApiDocumentProvider -{ - private readonly string specificationPath; - private readonly ILogger logger; - private readonly SemaphoreSlim refreshLock = new(1, 1); - private OpenApiDocumentSnapshot? cached; - - public AuthorityOpenApiDocumentProvider(IWebHostEnvironment environment, ILogger logger) - { - ArgumentNullException.ThrowIfNull(environment); - ArgumentNullException.ThrowIfNull(logger); - - specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml"); - this.logger = logger; - } - - public async ValueTask GetDocumentAsync(CancellationToken cancellationToken) - { - var lastWriteUtc = GetLastWriteTimeUtc(); - var current = cached; - if (current is not null && current.LastWriteUtc == lastWriteUtc) - { - return current; - } - - await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - current = cached; - lastWriteUtc = GetLastWriteTimeUtc(); - if (current is not null && current.LastWriteUtc == lastWriteUtc) - { - return current; - } - - var snapshot = LoadSnapshot(lastWriteUtc); - cached = snapshot; - return snapshot; - } - finally - { - refreshLock.Release(); - } - } - - private DateTime GetLastWriteTimeUtc() - { - var file = new FileInfo(specificationPath); - if (!file.Exists) - { - throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath); - } - - return file.LastWriteTimeUtc; - } - - private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc) - { - string yamlText; - try - { - yamlText = File.ReadAllText(specificationPath); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath); - throw; - } - - var yamlStream = new YamlStream(); - using (var reader = new StringReader(yamlText)) - { - yamlStream.Load(reader); - } - - if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode) - { - throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node."); - } - - var (grants, scopes) = CollectGrantsAndScopes(rootNode); - - if (!TryGetMapping(rootNode, "info", out var infoNode)) - { - infoNode = new YamlMappingNode(); - rootNode.Children[new YamlScalarNode("info")] = infoNode; - } - - var serviceName = "authority"; - var buildVersion = ResolveBuildVersion(); - ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes); - - var apiVersion = TryGetScalar(infoNode, "version", out var version) - ? version - : "0.0.0"; - - var updatedYaml = WriteYaml(yamlStream); - var json = ConvertYamlToJson(updatedYaml); - var etag = CreateStrongEtag(json); - - return new OpenApiDocumentSnapshot( - serviceName, - apiVersion, - buildVersion, - json, - updatedYaml, - etag, - lastWriteUtc, - grants, - scopes); - } - - private static (IReadOnlyList Grants, IReadOnlyList Scopes) CollectGrantsAndScopes(YamlMappingNode root) - { - if (!TryGetMapping(root, "components", out var components) || - !TryGetMapping(components, "securitySchemes", out var securitySchemes)) - { - return (Array.Empty(), Array.Empty()); - } - - var grants = new SortedSet(StringComparer.Ordinal); - var scopes = new SortedSet(StringComparer.Ordinal); - - foreach (var scheme in securitySchemes.Children.Values.OfType()) - { - if (!TryGetMapping(scheme, "flows", out var flows)) - { - continue; - } - - foreach (var flowEntry in flows.Children) - { - if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping) - { - continue; - } - - var grant = NormalizeGrantName(flowNameNode.Value); - if (grant is not null) - { - grants.Add(grant); - } - - if (TryGetMapping(flowMapping, "scopes", out var scopesMapping)) - { - foreach (var scope in scopesMapping.Children.Keys.OfType()) - { - if (!string.IsNullOrWhiteSpace(scope.Value)) - { - scopes.Add(scope.Value); - } - } - } - - if (flowMapping.Children.TryGetValue(new YamlScalarNode("refreshUrl"), out var refreshNode) && - refreshNode is YamlScalarNode refreshScalar && !string.IsNullOrWhiteSpace(refreshScalar.Value)) - { - grants.Add("refresh_token"); - } - } - } - - return ( - grants.Count == 0 ? Array.Empty() : grants.ToArray(), - scopes.Count == 0 ? Array.Empty() : scopes.ToArray()); - } - - private static string? NormalizeGrantName(string? flowName) - => flowName switch - { - null or "" => null, - "authorizationCode" => "authorization_code", - "clientCredentials" => "client_credentials", - "password" => "password", - "implicit" => "implicit", - "deviceCode" => "device_code", - _ => flowName - }; - - private static void ApplyInfoMetadata( - YamlMappingNode infoNode, - string serviceName, - string buildVersion, - IReadOnlyList grants, - IReadOnlyList scopes) - { - infoNode.Children[new YamlScalarNode("x-stella-service")] = new YamlScalarNode(serviceName); - infoNode.Children[new YamlScalarNode("x-stella-build-version")] = new YamlScalarNode(buildVersion); - infoNode.Children[new YamlScalarNode("x-stella-grant-types")] = CreateSequence(grants); - infoNode.Children[new YamlScalarNode("x-stella-scopes")] = CreateSequence(scopes); - } - - private static YamlSequenceNode CreateSequence(IEnumerable values) - { - var sequence = new YamlSequenceNode(); - foreach (var value in values) - { - sequence.Add(new YamlScalarNode(value)); - } - - return sequence; - } - - private static bool TryGetMapping(YamlMappingNode node, string key, out YamlMappingNode mapping) - { - foreach (var entry in node.Children) - { - if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal)) - { - if (entry.Value is YamlMappingNode mappingNode) - { - mapping = mappingNode; - return true; - } - - break; - } - } - - mapping = null!; - return false; - } - - private static bool TryGetScalar(YamlMappingNode node, string key, out string value) - { - foreach (var entry in node.Children) - { - if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal)) - { - if (entry.Value is YamlScalarNode valueNode) - { - value = valueNode.Value ?? string.Empty; - return true; - } - - break; - } - } - - value = string.Empty; - return false; - } - - private static string WriteYaml(YamlStream yamlStream) - { - using var writer = new StringWriter(CultureInfo.InvariantCulture); - yamlStream.Save(writer, assignAnchors: false); - return writer.ToString(); - } - - private static string ConvertYamlToJson(string yaml) - { - var deserializer = new DeserializerBuilder().Build(); - var yamlObject = deserializer.Deserialize(new StringReader(yaml)); - - var serializer = new SerializerBuilder() - .JsonCompatible() - .Build(); - - var json = serializer.Serialize(yamlObject); - return string.IsNullOrWhiteSpace(json) ? "{}" : json.Trim(); - } - - private static string CreateStrongEtag(string jsonRepresentation) - { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(jsonRepresentation)); - var hash = Convert.ToHexString(bytes).ToLowerInvariant(); - return $"\"{hash}\""; - } - - private static string ResolveBuildVersion() - { - var assembly = typeof(AuthorityOpenApiDocumentProvider).Assembly; - var informational = assembly - .GetCustomAttribute()? - .InformationalVersion; - - if (!string.IsNullOrWhiteSpace(informational)) - { - return informational!; - } - - var version = assembly.GetName().Version; - return version?.ToString() ?? "unknown"; - } -} - -internal sealed record OpenApiDocumentSnapshot( - string ServiceName, - string ApiVersion, - string BuildVersion, - string Json, - string Yaml, - string ETag, - DateTime LastWriteUtc, - IReadOnlyList GrantTypes, - IReadOnlyList Scopes); +using System.Collections.Generic; +using System.IO; +using System.Globalization; +using System.Linq; +using System.Reflection; +using StellaOps.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; +using YamlDotNet.Serialization; + +namespace StellaOps.Authority.OpenApi; + +internal sealed class AuthorityOpenApiDocumentProvider +{ + private readonly string specificationPath; + private readonly ILogger logger; + private readonly ICryptoHash hash; + private readonly SemaphoreSlim refreshLock = new(1, 1); + private OpenApiDocumentSnapshot? cached; + + public AuthorityOpenApiDocumentProvider( + IWebHostEnvironment environment, + ILogger logger, + ICryptoHash hash) + { + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(hash); + + specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml"); + this.logger = logger; + this.hash = hash; + } + + public async ValueTask GetDocumentAsync(CancellationToken cancellationToken) + { + var lastWriteUtc = GetLastWriteTimeUtc(); + var current = cached; + if (current is not null && current.LastWriteUtc == lastWriteUtc) + { + return current; + } + + await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + current = cached; + lastWriteUtc = GetLastWriteTimeUtc(); + if (current is not null && current.LastWriteUtc == lastWriteUtc) + { + return current; + } + + var snapshot = LoadSnapshot(lastWriteUtc); + cached = snapshot; + return snapshot; + } + finally + { + refreshLock.Release(); + } + } + + private DateTime GetLastWriteTimeUtc() + { + var file = new FileInfo(specificationPath); + if (!file.Exists) + { + throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath); + } + + return file.LastWriteTimeUtc; + } + + private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc) + { + string yamlText; + try + { + yamlText = File.ReadAllText(specificationPath); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath); + throw; + } + + var yamlStream = new YamlStream(); + using (var reader = new StringReader(yamlText)) + { + yamlStream.Load(reader); + } + + if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode) + { + throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node."); + } + + var (grants, scopes) = CollectGrantsAndScopes(rootNode); + + if (!TryGetMapping(rootNode, "info", out var infoNode)) + { + infoNode = new YamlMappingNode(); + rootNode.Children[new YamlScalarNode("info")] = infoNode; + } + + var serviceName = "authority"; + var buildVersion = ResolveBuildVersion(); + ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes); + + var apiVersion = TryGetScalar(infoNode, "version", out var version) + ? version + : "0.0.0"; + + var updatedYaml = WriteYaml(yamlStream); + var json = ConvertYamlToJson(updatedYaml); + var etag = CreateStrongEtag(json); + + return new OpenApiDocumentSnapshot( + serviceName, + apiVersion, + buildVersion, + json, + updatedYaml, + etag, + lastWriteUtc, + grants, + scopes); + } + + private static (IReadOnlyList Grants, IReadOnlyList Scopes) CollectGrantsAndScopes(YamlMappingNode root) + { + if (!TryGetMapping(root, "components", out var components) || + !TryGetMapping(components, "securitySchemes", out var securitySchemes)) + { + return (Array.Empty(), Array.Empty()); + } + + var grants = new SortedSet(StringComparer.Ordinal); + var scopes = new SortedSet(StringComparer.Ordinal); + + foreach (var scheme in securitySchemes.Children.Values.OfType()) + { + if (!TryGetMapping(scheme, "flows", out var flows)) + { + continue; + } + + foreach (var flowEntry in flows.Children) + { + if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping) + { + continue; + } + + var grant = NormalizeGrantName(flowNameNode.Value); + if (grant is not null) + { + grants.Add(grant); + } + + if (TryGetMapping(flowMapping, "scopes", out var scopesMapping)) + { + foreach (var scope in scopesMapping.Children.Keys.OfType()) + { + if (!string.IsNullOrWhiteSpace(scope.Value)) + { + scopes.Add(scope.Value); + } + } + } + + if (flowMapping.Children.TryGetValue(new YamlScalarNode("refreshUrl"), out var refreshNode) && + refreshNode is YamlScalarNode refreshScalar && !string.IsNullOrWhiteSpace(refreshScalar.Value)) + { + grants.Add("refresh_token"); + } + } + } + + return ( + grants.Count == 0 ? Array.Empty() : grants.ToArray(), + scopes.Count == 0 ? Array.Empty() : scopes.ToArray()); + } + + private static string? NormalizeGrantName(string? flowName) + => flowName switch + { + null or "" => null, + "authorizationCode" => "authorization_code", + "clientCredentials" => "client_credentials", + "password" => "password", + "implicit" => "implicit", + "deviceCode" => "device_code", + _ => flowName + }; + + private static void ApplyInfoMetadata( + YamlMappingNode infoNode, + string serviceName, + string buildVersion, + IReadOnlyList grants, + IReadOnlyList scopes) + { + infoNode.Children[new YamlScalarNode("x-stella-service")] = new YamlScalarNode(serviceName); + infoNode.Children[new YamlScalarNode("x-stella-build-version")] = new YamlScalarNode(buildVersion); + infoNode.Children[new YamlScalarNode("x-stella-grant-types")] = CreateSequence(grants); + infoNode.Children[new YamlScalarNode("x-stella-scopes")] = CreateSequence(scopes); + } + + private static YamlSequenceNode CreateSequence(IEnumerable values) + { + var sequence = new YamlSequenceNode(); + foreach (var value in values) + { + sequence.Add(new YamlScalarNode(value)); + } + + return sequence; + } + + private static bool TryGetMapping(YamlMappingNode node, string key, out YamlMappingNode mapping) + { + foreach (var entry in node.Children) + { + if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal)) + { + if (entry.Value is YamlMappingNode mappingNode) + { + mapping = mappingNode; + return true; + } + + break; + } + } + + mapping = null!; + return false; + } + + private static bool TryGetScalar(YamlMappingNode node, string key, out string value) + { + foreach (var entry in node.Children) + { + if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal)) + { + if (entry.Value is YamlScalarNode valueNode) + { + value = valueNode.Value ?? string.Empty; + return true; + } + + break; + } + } + + value = string.Empty; + return false; + } + + private static string WriteYaml(YamlStream yamlStream) + { + using var writer = new StringWriter(CultureInfo.InvariantCulture); + yamlStream.Save(writer, assignAnchors: false); + return writer.ToString(); + } + + private static string ConvertYamlToJson(string yaml) + { + var deserializer = new DeserializerBuilder().Build(); + var yamlObject = deserializer.Deserialize(new StringReader(yaml)); + + var serializer = new SerializerBuilder() + .JsonCompatible() + .Build(); + + var json = serializer.Serialize(yamlObject); + return string.IsNullOrWhiteSpace(json) ? "{}" : json.Trim(); + } + + private string CreateStrongEtag(string jsonRepresentation) + { + var digest = hash.ComputeHashHex(Encoding.UTF8.GetBytes(jsonRepresentation), HashAlgorithms.Sha256); + return "\"" + digest + "\""; + } + + private static string ResolveBuildVersion() + { + var assembly = typeof(AuthorityOpenApiDocumentProvider).Assembly; + var informational = assembly + .GetCustomAttribute()? + .InformationalVersion; + + if (!string.IsNullOrWhiteSpace(informational)) + { + return informational!; + } + + var version = assembly.GetName().Version; + return version?.ToString() ?? "unknown"; + } +} + +internal sealed record OpenApiDocumentSnapshot( + string ServiceName, + string ApiVersion, + string BuildVersion, + string Json, + string Yaml, + string ETag, + DateTime LastWriteUtc, + IReadOnlyList GrantTypes, + IReadOnlyList Scopes); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs index 78a711043..2e69591bf 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/AuthorityOpenIddictConstants.cs @@ -24,8 +24,10 @@ internal static class AuthorityOpenIddictConstants internal const string DpopConsumedNonceProperty = "authority:dpop_nonce"; internal const string ConfirmationClaimType = "cnf"; internal const string SenderConstraintClaimType = "authority_sender_constraint"; + internal const string SenderNonceClaimType = "authority_sender_nonce"; internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint"; internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex"; + internal const string MtlsCertificateHexClaimType = "authority_sender_certificate_hex"; internal const string ClientTenantProperty = "authority:client_tenant"; internal const string ClientProjectProperty = "authority:client_project"; internal const string ClientAttributesProperty = "authority:client_attributes"; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs index bc0199299..7f56a4a1b 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs @@ -1177,6 +1177,50 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle var extraProperties = new List(); + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var auditSenderConstraintObj) && + auditSenderConstraintObj is string auditSenderConstraint && + !string.IsNullOrWhiteSpace(auditSenderConstraint)) + { + extraProperties.Add(new AuthEventProperty + { + Name = "sender.constraint", + Value = ClassifiedString.Public(auditSenderConstraint) + }); + } + + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var auditDpopThumbprintObj) && + auditDpopThumbprintObj is string auditDpopThumbprint && + !string.IsNullOrWhiteSpace(auditDpopThumbprint)) + { + extraProperties.Add(new AuthEventProperty + { + Name = "sender.dpop.jkt", + Value = ClassifiedString.Sensitive(auditDpopThumbprint) + }); + } + + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var auditMtlsThumbprintObj) && + auditMtlsThumbprintObj is string auditMtlsThumbprint && + !string.IsNullOrWhiteSpace(auditMtlsThumbprint)) + { + extraProperties.Add(new AuthEventProperty + { + Name = "sender.mtls.x5t", + Value = ClassifiedString.Sensitive(auditMtlsThumbprint) + }); + } + + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var auditMtlsHexObj) && + auditMtlsHexObj is string auditMtlsHex && + !string.IsNullOrWhiteSpace(auditMtlsHex)) + { + extraProperties.Add(new AuthEventProperty + { + Name = "sender.mtls.x5t_hex", + Value = ClassifiedString.Sensitive(auditMtlsHex) + }); + } + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorReasonProperty, out var operatorReasonObj) && operatorReasonObj is string operatorReason && !string.IsNullOrWhiteSpace(operatorReason)) @@ -1873,6 +1917,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< record.SenderKeyThumbprint = senderThumbprint; } + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var senderCertHexObj) && + senderCertHexObj is string senderCertHex && + !string.IsNullOrWhiteSpace(senderCertHex)) + { + record.SenderCertificateHex = senderCertHex; + } + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantObj) && tenantObj is string tenantValue && !string.IsNullOrWhiteSpace(tenantValue)) @@ -1976,6 +2027,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation); } + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) && + nonceObj is string consumedNonce && + !string.IsNullOrWhiteSpace(consumedNonce)) + { + identity.SetClaim(AuthorityOpenIddictConstants.SenderNonceClaimType, consumedNonce); + } + break; case AuthoritySenderConstraintKinds.Mtls: if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) && @@ -1990,6 +2048,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation); } + if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var mtlsHexObj) && + mtlsHexObj is string mtlsHex && + !string.IsNullOrWhiteSpace(mtlsHex)) + { + identity.SetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, mtlsHex); + } + break; } } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/DpopHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/DpopHandlers.cs index 5acf07bc4..9210a1a67 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/DpopHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/DpopHandlers.cs @@ -26,11 +26,13 @@ using Microsoft.IdentityModel.Tokens; namespace StellaOps.Authority.OpenIddict.Handlers; -internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler -{ - private readonly StellaOpsAuthorityOptions authorityOptions; - private readonly IAuthorityClientStore clientStore; - private readonly IDpopProofValidator proofValidator; +internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler +{ + private const string AnyDpopKeyThumbprint = "__authority_any_dpop_key__"; + + private readonly StellaOpsAuthorityOptions authorityOptions; + private readonly IAuthorityClientStore clientStore; + private readonly IDpopProofValidator proofValidator; private readonly IDpopNonceStore nonceStore; private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; private readonly IAuthEventSink auditSink; @@ -88,15 +90,34 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler ConsumeNonceAsync( + string nonce, + string audience, + AuthorityClientDocument clientDocument, + string keyThumbprint, + CancellationToken cancellationToken) + { + var result = await nonceStore.TryConsumeAsync( + nonce, + audience, + clientDocument.ClientId, + keyThumbprint, + cancellationToken).ConfigureAwait(false); + + if (result.Status == DpopNonceConsumeStatus.NotFound && + !string.Equals(keyThumbprint, AnyDpopKeyThumbprint, StringComparison.Ordinal)) + { + result = await nonceStore.TryConsumeAsync( + nonce, + audience, + clientDocument.ClientId, + AnyDpopKeyThumbprint, + cancellationToken).ConfigureAwait(false); + } + + return result; + } private static string BuildAuthenticateHeader(string reasonCode, string description, string? nonce) { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs index 35b643a9b..c97083cac 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs @@ -117,6 +117,18 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler { private readonly IAuthorityTokenStore tokenStore; private readonly IAuthorityMongoSessionAccessor sessionAccessor; private readonly IAuthorityClientStore clientStore; - private readonly IAuthorityIdentityProviderRegistry registry; - private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; - private readonly IAuthEventSink auditSink; + private readonly IAuthorityIdentityProviderRegistry registry; + private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; + private readonly IAuthEventSink auditSink; private readonly TimeProvider clock; private readonly ActivitySource activitySource; private readonly ILogger logger; + private readonly Counter mtlsMismatchCounter; private static readonly TimeSpan IncidentFreshAuthWindow = TimeSpan.FromMinutes(5); private static readonly TimeSpan PolicyAttestationFreshAuthWindow = TimeSpan.FromMinutes(5); - - public ValidateAccessTokenHandler( - IAuthorityTokenStore tokenStore, - IAuthorityMongoSessionAccessor sessionAccessor, - IAuthorityClientStore clientStore, - IAuthorityIdentityProviderRegistry registry, - IAuthorityRateLimiterMetadataAccessor metadataAccessor, - IAuthEventSink auditSink, - TimeProvider clock, - ActivitySource activitySource, - ILogger logger) - { - this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); - this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor)); - this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); - this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); - this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); - this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); - this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); - this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenContext context) - { - ArgumentNullException.ThrowIfNull(context); - - if (context.Principal is null) - { - return; - } - - if (context.EndpointType is not (OpenIddictServerEndpointType.Token or OpenIddictServerEndpointType.Introspection)) - { - return; - } - - static string? NormalizeTenant(string? value) - => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); - - static string NormalizeProject(string? value) - => string.IsNullOrWhiteSpace(value) - ? StellaOpsTenancyDefaults.AnyProject - : value.Trim().ToLowerInvariant(); - - var identity = context.Principal.Identity as ClaimsIdentity; - var principalTenant = NormalizeTenant(context.Principal.GetClaim(StellaOpsClaimTypes.Tenant)); - var principalProject = NormalizeProject(context.Principal.GetClaim(StellaOpsClaimTypes.Project)); - - using var activity = activitySource.StartActivity("authority.token.validate_access", ActivityKind.Internal); - activity?.SetTag("authority.endpoint", context.EndpointType switch - { - OpenIddictServerEndpointType.Token => "/token", - OpenIddictServerEndpointType.Introspection => "/introspect", - _ => context.EndpointType.ToString() - }); - - var tokenId = !string.IsNullOrWhiteSpace(context.TokenId) - ? context.TokenId - : context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId); - - var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false); - - AuthorityTokenDocument? tokenDocument = null; - if (!string.IsNullOrWhiteSpace(tokenId)) - { - tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken, session).ConfigureAwait(false); - if (tokenDocument is not null) - { - if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase)) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token is no longer active."); - logger.LogWarning("Access token {TokenId} rejected: status {Status}.", tokenId, tokenDocument.Status); - return; - } - - if (tokenDocument.ExpiresAt is { } expiresAt && expiresAt <= clock.GetUtcNow()) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token has expired."); - logger.LogWarning("Access token {TokenId} rejected: expired at {ExpiresAt:o}.", tokenId, expiresAt); - return; - } - - context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = tokenDocument; - activity?.SetTag("authority.token_id", tokenDocument.TokenId); - } - } - - if (tokenDocument is not null) - { - EnsureSenderConstraintClaims(context.Principal, tokenDocument); - - var documentTenant = NormalizeTenant(tokenDocument.Tenant); - if (documentTenant is not null) - { - if (principalTenant is null) - { - if (identity is not null) - { - identity.SetClaim(StellaOpsClaimTypes.Tenant, documentTenant); - principalTenant = documentTenant; - } - } - else if (!string.Equals(principalTenant, documentTenant, StringComparison.Ordinal)) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the issued tenant."); - logger.LogWarning( - "Access token validation failed: tenant mismatch for token {TokenId}. PrincipalTenant={PrincipalTenant}; DocumentTenant={DocumentTenant}.", - tokenDocument.TokenId, - principalTenant, - documentTenant); - return; - } - - metadataAccessor.SetTenant(documentTenant); - } - - var documentProject = NormalizeProject(tokenDocument.Project); - if (identity is not null) - { - var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value; - if (string.IsNullOrWhiteSpace(existingProject)) - { - identity.SetClaim(StellaOpsClaimTypes.Project, documentProject); - principalProject = documentProject; - } - else - { - var normalizedExistingProject = NormalizeProject(existingProject); - if (!string.Equals(normalizedExistingProject, documentProject, StringComparison.Ordinal)) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project."); - logger.LogWarning( - "Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.", - tokenDocument.TokenId, - normalizedExistingProject, - documentProject); - return; - } - - principalProject = normalizedExistingProject; - } - } - else if (!string.Equals(principalProject, documentProject, StringComparison.Ordinal)) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project."); - logger.LogWarning( - "Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.", - tokenDocument.TokenId, - principalProject, - documentProject); - return; - } - else - { - principalProject = documentProject; - } - - metadataAccessor.SetProject(documentProject); - } - - if (!context.IsRejected && tokenDocument is not null) - { - await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false); - } - - var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId); - AuthorityClientDocument? clientDocument = null; - if (!string.IsNullOrWhiteSpace(clientId)) - { - clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false); - if (clientDocument is null || clientDocument.Disabled) - { - context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted."); - logger.LogWarning("Access token validation failed: client {ClientId} disabled or missing.", clientId); - return; - } - } - - if (clientDocument is not null && - clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var clientTenantRaw)) - { - var clientTenant = NormalizeTenant(clientTenantRaw); - if (clientTenant is not null) - { - if (principalTenant is null) - { - if (identity is not null) - { - identity.SetClaim(StellaOpsClaimTypes.Tenant, clientTenant); - principalTenant = clientTenant; - } - } - else if (!string.Equals(principalTenant, clientTenant, StringComparison.Ordinal)) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the registered client tenant."); - logger.LogWarning( - "Access token validation failed: tenant mismatch for client {ClientId}. PrincipalTenant={PrincipalTenant}; ClientTenant={ClientTenant}.", - clientId, - principalTenant, - clientTenant); - return; - } - - metadataAccessor.SetTenant(clientTenant); - } - } - - if (clientDocument is not null && - clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var clientProjectRaw)) - { - var clientProject = NormalizeProject(clientProjectRaw); - if (!string.Equals(principalProject, clientProject, StringComparison.Ordinal)) - { - if (identity is not null) - { - var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value; - if (string.IsNullOrWhiteSpace(existingProject)) - { - identity.SetClaim(StellaOpsClaimTypes.Project, clientProject); - principalProject = clientProject; - } - else - { - var normalizedExistingProject = NormalizeProject(existingProject); - if (!string.Equals(normalizedExistingProject, clientProject, StringComparison.Ordinal)) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project."); - logger.LogWarning( - "Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.", - clientId, - normalizedExistingProject, - clientProject); - return; - } - - principalProject = normalizedExistingProject; - } - } - else - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project."); - logger.LogWarning( - "Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.", - clientId, - principalProject, - clientProject); - return; - } - } - - metadataAccessor.SetProject(clientProject); - } - - if (identity is null) - { - return; - } - - if (principalTenant is not null) - { - metadataAccessor.SetTenant(principalTenant); - } - - if (!string.IsNullOrWhiteSpace(principalProject)) - { - metadataAccessor.SetProject(principalProject); - activity?.SetTag("authority.project", principalProject); - } - - var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider); - if (string.IsNullOrWhiteSpace(providerName)) - { - return; - } - - if (!registry.TryGet(providerName, out var providerMetadata)) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The identity provider associated with the token is unavailable."); - logger.LogWarning("Access token validation failed: provider {Provider} unavailable for subject {Subject}.", providerName, context.Principal.GetClaim(OpenIddictConstants.Claims.Subject)); - return; - } - - await using var providerHandle = await registry.AcquireAsync(providerMetadata.Name, context.CancellationToken).ConfigureAwait(false); - var provider = providerHandle.Provider; - - AuthorityUserDescriptor? user = null; - AuthorityClientDescriptor? client = null; - - var subject = context.Principal.GetClaim(OpenIddictConstants.Claims.Subject); - if (!string.IsNullOrWhiteSpace(subject)) - { - user = await provider.Credentials.FindBySubjectAsync(subject, context.CancellationToken).ConfigureAwait(false); - if (user is null) - { - context.Reject(OpenIddictConstants.Errors.InvalidToken, "The subject referenced by the token no longer exists."); - logger.LogWarning("Access token validation failed: subject {SubjectId} not found.", subject); - return; - } - activity?.SetTag("authority.subject_id", subject); - } - - if (!string.IsNullOrWhiteSpace(clientId) && provider.ClientProvisioning is not null) - { - client = await provider.ClientProvisioning.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); - } - + + public ValidateAccessTokenHandler( + IAuthorityTokenStore tokenStore, + IAuthorityMongoSessionAccessor sessionAccessor, + IAuthorityClientStore clientStore, + IAuthorityIdentityProviderRegistry registry, + IAuthorityRateLimiterMetadataAccessor metadataAccessor, + IAuthEventSink auditSink, + TimeProvider clock, + ActivitySource activitySource, + Meter meter, + ILogger logger) + { + this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); + this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor)); + this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); + this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); + this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); + this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); + this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); + this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); + if (meter is null) + { + throw new ArgumentNullException(nameof(meter)); + } + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + mtlsMismatchCounter = meter.CreateCounter( + name: "authority_mtls_mismatch_total", + description: "Count of mTLS-bound token requests rejected due to missing or mismatched certificates."); + } + + public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Principal is null) + { + return; + } + + if (context.EndpointType is not (OpenIddictServerEndpointType.Token or OpenIddictServerEndpointType.Introspection)) + { + return; + } + + static string? NormalizeTenant(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + + static string NormalizeProject(string? value) + => string.IsNullOrWhiteSpace(value) + ? StellaOpsTenancyDefaults.AnyProject + : value.Trim().ToLowerInvariant(); + + var identity = context.Principal.Identity as ClaimsIdentity; + var principalTenant = NormalizeTenant(context.Principal.GetClaim(StellaOpsClaimTypes.Tenant)); + var principalProject = NormalizeProject(context.Principal.GetClaim(StellaOpsClaimTypes.Project)); + + using var activity = activitySource.StartActivity("authority.token.validate_access", ActivityKind.Internal); + activity?.SetTag("authority.endpoint", context.EndpointType switch + { + OpenIddictServerEndpointType.Token => "/token", + OpenIddictServerEndpointType.Introspection => "/introspect", + _ => context.EndpointType.ToString() + }); + + var tokenId = !string.IsNullOrWhiteSpace(context.TokenId) + ? context.TokenId + : context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId); + + var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false); + + AuthorityTokenDocument? tokenDocument = null; + if (!string.IsNullOrWhiteSpace(tokenId)) + { + tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken, session).ConfigureAwait(false); + if (tokenDocument is not null) + { + if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase)) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token is no longer active."); + logger.LogWarning("Access token {TokenId} rejected: status {Status}.", tokenId, tokenDocument.Status); + return; + } + + if (tokenDocument.ExpiresAt is { } expiresAt && expiresAt <= clock.GetUtcNow()) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token has expired."); + logger.LogWarning("Access token {TokenId} rejected: expired at {ExpiresAt:o}.", tokenId, expiresAt); + return; + } + + context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = tokenDocument; + activity?.SetTag("authority.token_id", tokenDocument.TokenId); + } + } + + if (tokenDocument is not null) + { + EnsureSenderConstraintClaims(context.Principal, tokenDocument); + + if (!EnsureMtlsBinding(context, tokenDocument)) + { + return; + } + + var documentTenant = NormalizeTenant(tokenDocument.Tenant); + if (documentTenant is not null) + { + if (principalTenant is null) + { + if (identity is not null) + { + identity.SetClaim(StellaOpsClaimTypes.Tenant, documentTenant); + principalTenant = documentTenant; + } + } + else if (!string.Equals(principalTenant, documentTenant, StringComparison.Ordinal)) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the issued tenant."); + logger.LogWarning( + "Access token validation failed: tenant mismatch for token {TokenId}. PrincipalTenant={PrincipalTenant}; DocumentTenant={DocumentTenant}.", + tokenDocument.TokenId, + principalTenant, + documentTenant); + return; + } + + metadataAccessor.SetTenant(documentTenant); + } + + var documentProject = NormalizeProject(tokenDocument.Project); + if (identity is not null) + { + var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value; + if (string.IsNullOrWhiteSpace(existingProject)) + { + identity.SetClaim(StellaOpsClaimTypes.Project, documentProject); + principalProject = documentProject; + } + else + { + var normalizedExistingProject = NormalizeProject(existingProject); + if (!string.Equals(normalizedExistingProject, documentProject, StringComparison.Ordinal)) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project."); + logger.LogWarning( + "Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.", + tokenDocument.TokenId, + normalizedExistingProject, + documentProject); + return; + } + + principalProject = normalizedExistingProject; + } + } + else if (!string.Equals(principalProject, documentProject, StringComparison.Ordinal)) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project."); + logger.LogWarning( + "Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.", + tokenDocument.TokenId, + principalProject, + documentProject); + return; + } + else + { + principalProject = documentProject; + } + + metadataAccessor.SetProject(documentProject); + } + + if (!context.IsRejected && tokenDocument is not null) + { + await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false); + } + + var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId); + AuthorityClientDocument? clientDocument = null; + if (!string.IsNullOrWhiteSpace(clientId)) + { + clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false); + if (clientDocument is null || clientDocument.Disabled) + { + context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted."); + logger.LogWarning("Access token validation failed: client {ClientId} disabled or missing.", clientId); + return; + } + } + + if (clientDocument is not null && + clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var clientTenantRaw)) + { + var clientTenant = NormalizeTenant(clientTenantRaw); + if (clientTenant is not null) + { + if (principalTenant is null) + { + if (identity is not null) + { + identity.SetClaim(StellaOpsClaimTypes.Tenant, clientTenant); + principalTenant = clientTenant; + } + } + else if (!string.Equals(principalTenant, clientTenant, StringComparison.Ordinal)) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the registered client tenant."); + logger.LogWarning( + "Access token validation failed: tenant mismatch for client {ClientId}. PrincipalTenant={PrincipalTenant}; ClientTenant={ClientTenant}.", + clientId, + principalTenant, + clientTenant); + return; + } + + metadataAccessor.SetTenant(clientTenant); + } + } + + if (clientDocument is not null && + clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var clientProjectRaw)) + { + var clientProject = NormalizeProject(clientProjectRaw); + if (!string.Equals(principalProject, clientProject, StringComparison.Ordinal)) + { + if (identity is not null) + { + var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value; + if (string.IsNullOrWhiteSpace(existingProject)) + { + identity.SetClaim(StellaOpsClaimTypes.Project, clientProject); + principalProject = clientProject; + } + else + { + var normalizedExistingProject = NormalizeProject(existingProject); + if (!string.Equals(normalizedExistingProject, clientProject, StringComparison.Ordinal)) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project."); + logger.LogWarning( + "Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.", + clientId, + normalizedExistingProject, + clientProject); + return; + } + + principalProject = normalizedExistingProject; + } + } + else + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project."); + logger.LogWarning( + "Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.", + clientId, + principalProject, + clientProject); + return; + } + } + + metadataAccessor.SetProject(clientProject); + } + + if (identity is null) + { + return; + } + + if (principalTenant is not null) + { + metadataAccessor.SetTenant(principalTenant); + } + + if (!string.IsNullOrWhiteSpace(principalProject)) + { + metadataAccessor.SetProject(principalProject); + activity?.SetTag("authority.project", principalProject); + } + + var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider); + if (string.IsNullOrWhiteSpace(providerName)) + { + return; + } + + if (!registry.TryGet(providerName, out var providerMetadata)) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The identity provider associated with the token is unavailable."); + logger.LogWarning("Access token validation failed: provider {Provider} unavailable for subject {Subject}.", providerName, context.Principal.GetClaim(OpenIddictConstants.Claims.Subject)); + return; + } + + await using var providerHandle = await registry.AcquireAsync(providerMetadata.Name, context.CancellationToken).ConfigureAwait(false); + var provider = providerHandle.Provider; + + AuthorityUserDescriptor? user = null; + AuthorityClientDescriptor? client = null; + + var subject = context.Principal.GetClaim(OpenIddictConstants.Claims.Subject); + if (!string.IsNullOrWhiteSpace(subject)) + { + user = await provider.Credentials.FindBySubjectAsync(subject, context.CancellationToken).ConfigureAwait(false); + if (user is null) + { + context.Reject(OpenIddictConstants.Errors.InvalidToken, "The subject referenced by the token no longer exists."); + logger.LogWarning("Access token validation failed: subject {SubjectId} not found.", subject); + return; + } + activity?.SetTag("authority.subject_id", subject); + } + + if (!string.IsNullOrWhiteSpace(clientId) && provider.ClientProvisioning is not null) + { + client = await provider.ClientProvisioning.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); + } + if (context.Principal.HasScope(StellaOpsScopes.ObservabilityIncident)) { var authTimeClaim = context.Principal.GetClaim(OpenIddictConstants.Claims.AuthenticationTime); @@ -433,150 +450,227 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler - { - new() { Name = "token.id", Value = ClassifiedString.Sensitive(tokenDocument.TokenId) }, - new() { Name = "token.type", Value = ClassifiedString.Public(tokenDocument.Type) }, - new() { Name = "token.devices.total", Value = ClassifiedString.Public((previousCount + 1).ToString(CultureInfo.InvariantCulture)) } - }; - - if (!string.IsNullOrWhiteSpace(tokenDocument.ClientId)) - { - properties.Add(new AuthEventProperty - { - Name = "token.client_id", - Value = ClassifiedString.Personal(tokenDocument.ClientId) - }); - } - - logger.LogWarning("Detected suspected token replay for token {TokenId} (client {ClientId}).", tokenDocument.TokenId, clientId ?? ""); - - var record = new AuthEventRecord - { - EventType = "authority.token.replay.suspected", - OccurredAt = observedAt, - CorrelationId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"), - Outcome = AuthEventOutcome.Error, - Reason = "Token observed from a new device fingerprint.", - Subject = subject, - Client = client, - Scopes = Array.Empty(), - Network = network, - Properties = properties - }; - - await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false); - } - - private static void EnsureSenderConstraintClaims(ClaimsPrincipal? principal, AuthorityTokenDocument tokenDocument) - { - if (principal?.Identity is not ClaimsIdentity identity) - { - return; - } - - if (!string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) && - !identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.SenderConstraintClaimType)) - { - identity.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, tokenDocument.SenderConstraint); - } - - if (identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.ConfirmationClaimType)) - { - return; - } - - if (string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) || string.IsNullOrWhiteSpace(tokenDocument.SenderKeyThumbprint)) - { - return; - } - - string confirmation = tokenDocument.SenderConstraint switch - { - AuthoritySenderConstraintKinds.Dpop => JsonSerializer.Serialize(new Dictionary - { - ["jkt"] = tokenDocument.SenderKeyThumbprint - }), - AuthoritySenderConstraintKinds.Mtls => JsonSerializer.Serialize(new Dictionary - { - ["x5t#S256"] = tokenDocument.SenderKeyThumbprint - }), - _ => string.Empty - }; - - if (!string.IsNullOrEmpty(confirmation)) - { - identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation); - } - } -} + + private async ValueTask TrackTokenUsageAsync( + OpenIddictServerEvents.ValidateTokenContext context, + AuthorityTokenDocument tokenDocument, + ClaimsPrincipal principal, + IClientSessionHandle session) + { + var metadata = metadataAccessor.GetMetadata(); + var remoteAddress = metadata?.RemoteIp; + var userAgent = metadata?.UserAgent; + + var observedAt = clock.GetUtcNow(); + var result = await tokenStore.RecordUsageAsync(tokenDocument.TokenId, remoteAddress, userAgent, observedAt, context.CancellationToken, session) + .ConfigureAwait(false); + + switch (result.Status) + { + case TokenUsageUpdateStatus.MissingMetadata: + logger.LogDebug("Token usage metadata missing for token {TokenId}; replay detection skipped.", tokenDocument.TokenId); + break; + case TokenUsageUpdateStatus.NotFound: + logger.LogWarning("Token usage recording failed: token {TokenId} not found.", tokenDocument.TokenId); + break; + case TokenUsageUpdateStatus.Recorded: + metadataAccessor.SetTag("authority.token_usage", "recorded"); + break; + case TokenUsageUpdateStatus.SuspectedReplay: + metadataAccessor.SetTag("authority.token_usage", "suspected_replay"); + await EmitReplayAuditAsync(tokenDocument, principal, metadata, result, observedAt, context.CancellationToken).ConfigureAwait(false); + break; + } + } + + private async ValueTask EmitReplayAuditAsync( + AuthorityTokenDocument tokenDocument, + ClaimsPrincipal principal, + AuthorityRateLimiterMetadata? metadata, + TokenUsageUpdateResult result, + DateTimeOffset observedAt, + CancellationToken cancellationToken) + { + var clientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId); + var subjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject); + var realm = principal.GetClaim(StellaOpsClaimTypes.IdentityProvider); + + var subject = string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(realm) + ? null + : new AuthEventSubject + { + SubjectId = ClassifiedString.Personal(subjectId), + Realm = ClassifiedString.Public(string.IsNullOrWhiteSpace(realm) ? null : realm) + }; + + var client = string.IsNullOrWhiteSpace(clientId) + ? null + : new AuthEventClient + { + ClientId = ClassifiedString.Personal(clientId) + }; + + var network = metadata is null && result.RemoteAddress is null && result.UserAgent is null + ? null + : new AuthEventNetwork + { + RemoteAddress = ClassifiedString.Personal(result.RemoteAddress ?? metadata?.RemoteIp), + ForwardedFor = ClassifiedString.Personal(metadata?.ForwardedFor), + UserAgent = ClassifiedString.Personal(result.UserAgent ?? metadata?.UserAgent) + }; + + var previousCount = tokenDocument.Devices?.Count ?? 0; + var properties = new List + { + new() { Name = "token.id", Value = ClassifiedString.Sensitive(tokenDocument.TokenId) }, + new() { Name = "token.type", Value = ClassifiedString.Public(tokenDocument.Type) }, + new() { Name = "token.devices.total", Value = ClassifiedString.Public((previousCount + 1).ToString(CultureInfo.InvariantCulture)) } + }; + + if (!string.IsNullOrWhiteSpace(tokenDocument.ClientId)) + { + properties.Add(new AuthEventProperty + { + Name = "token.client_id", + Value = ClassifiedString.Personal(tokenDocument.ClientId) + }); + } + + logger.LogWarning("Detected suspected token replay for token {TokenId} (client {ClientId}).", tokenDocument.TokenId, clientId ?? ""); + + var record = new AuthEventRecord + { + EventType = "authority.token.replay.suspected", + OccurredAt = observedAt, + CorrelationId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"), + Outcome = AuthEventOutcome.Error, + Reason = "Token observed from a new device fingerprint.", + Subject = subject, + Client = client, + Scopes = Array.Empty(), + Network = network, + Properties = properties + }; + + await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false); + } + + private bool EnsureMtlsBinding(OpenIddictServerEvents.ValidateTokenContext context, AuthorityTokenDocument tokenDocument) + { + if (!string.Equals(tokenDocument.SenderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal)) + { + return true; + } + + var expectedCertificateHex = tokenDocument.SenderCertificateHex; + if (string.IsNullOrWhiteSpace(expectedCertificateHex)) + { + logger.LogWarning( + "Token {TokenId} marked as mTLS but missing certificate metadata.", + tokenDocument.TokenId); + return true; + } + + if (!TryGetHttpContext(context.Transaction, out var httpContext)) + { + logger.LogWarning("mTLS-bound token {TokenId} used without HTTP context.", tokenDocument.TokenId); + context.Reject(OpenIddictConstants.Errors.InvalidToken, "Sender certificate verification unavailable."); + RecordMtlsMismatch("context_missing"); + return false; + } + + var certificate = httpContext.Connection.ClientCertificate; + if (certificate is null) + { + logger.LogWarning( + "mTLS-bound token {TokenId} used without presenting a client certificate.", + tokenDocument.TokenId); + context.Reject(OpenIddictConstants.Errors.InvalidToken, "Sender certificate required for this token."); + RecordMtlsMismatch("missing_certificate"); + return false; + } + + var presentedHex = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)); + if (!string.Equals(presentedHex, expectedCertificateHex, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning( + "mTLS-bound token {TokenId} rejected: certificate thumbprint mismatch.", + tokenDocument.TokenId); + context.Reject(OpenIddictConstants.Errors.InvalidToken, "Sender certificate mismatch."); + RecordMtlsMismatch("thumbprint_mismatch"); + return false; + } + + return true; + } + + private static bool TryGetHttpContext(OpenIddictServerTransaction transaction, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HttpContext? httpContext) + { + if (transaction.Properties.TryGetValue(typeof(HttpContext).FullName!, out var property) && + property is HttpContext typedContext) + { + httpContext = typedContext; + return true; + } + + httpContext = null; + return false; + } + + private void RecordMtlsMismatch(string reason) + { + mtlsMismatchCounter.Add(1, new KeyValuePair[] + { + new("reason", reason) + }); + } + + private static void EnsureSenderConstraintClaims(ClaimsPrincipal? principal, AuthorityTokenDocument tokenDocument) + { + if (principal?.Identity is not ClaimsIdentity identity) + { + return; + } + + if (!string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) && + !identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.SenderConstraintClaimType)) + { + identity.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, tokenDocument.SenderConstraint); + } + + if (identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.ConfirmationClaimType)) + { + return; + } + + if (string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) || string.IsNullOrWhiteSpace(tokenDocument.SenderKeyThumbprint)) + { + return; + } + + string confirmation = tokenDocument.SenderConstraint switch + { + AuthoritySenderConstraintKinds.Dpop => JsonSerializer.Serialize(new Dictionary + { + ["jkt"] = tokenDocument.SenderKeyThumbprint + }), + AuthoritySenderConstraintKinds.Mtls => JsonSerializer.Serialize(new Dictionary + { + ["x5t#S256"] = tokenDocument.SenderKeyThumbprint + }), + _ => string.Empty + }; + + if (!string.IsNullOrEmpty(confirmation)) + { + identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation); + } + + if (tokenDocument.SenderConstraint == AuthoritySenderConstraintKinds.Mtls && + !string.IsNullOrWhiteSpace(tokenDocument.SenderCertificateHex) && + !identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.MtlsCertificateHexClaimType)) + { + identity.SetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, tokenDocument.SenderCertificateHex); + } + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs index 31c859964..2cc355d4a 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs @@ -31,6 +31,7 @@ using StellaOps.Authority.Notifications.Ack; using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugins; using StellaOps.Authority.Bootstrap; +using StellaOps.Authority.Console; using StellaOps.Authority.Storage.Mongo.Extensions; using StellaOps.Authority.Storage.Mongo.Initialization; using StellaOps.Authority.Storage.Mongo.Stores; @@ -115,6 +116,8 @@ builder.Host.UseSerilog((context, _, loggerConfiguration) => }); var authorityOptions = authorityConfiguration.Options; +builder.Services.AddStellaOpsCrypto(authorityOptions.Crypto); +builder.Services.AddHostedService(); var issuerUri = authorityOptions.Issuer; if (issuerUri is null) { @@ -138,6 +141,7 @@ builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.AddSingleton(); +builder.Services.TryAddSingleton(); #if STELLAOPS_AUTH_SECURITY var senderConstraints = authorityOptions.Security.SenderConstraints; @@ -210,7 +214,6 @@ if (requiresKms) builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); } -builder.Services.AddStellaOpsCrypto(); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Security/AuthoritySecretHasherInitializer.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Security/AuthoritySecretHasherInitializer.cs new file mode 100644 index 000000000..cd86d3aed --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Security/AuthoritySecretHasherInitializer.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Authority.Plugins.Abstractions; +using StellaOps.Configuration; +using StellaOps.Cryptography; + +namespace StellaOps.Authority.Security; + +internal sealed class AuthoritySecretHasherInitializer : IHostedService +{ + private readonly ICryptoHash hash; + private readonly IOptions authorityOptions; + private readonly ILogger logger; + + public AuthoritySecretHasherInitializer( + ICryptoHash hash, + IOptions authorityOptions, + ILogger logger) + { + this.hash = hash ?? throw new ArgumentNullException(nameof(hash)); + this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + var options = authorityOptions.Value; + var algorithm = options?.Crypto?.DefaultHashAlgorithm; + AuthoritySecretHasher.Configure(hash, algorithm); + logger.LogInformation("Authority secret hasher configured with default algorithm {Algorithm}.", + string.IsNullOrWhiteSpace(algorithm) ? HashAlgorithms.Sha256 : algorithm); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs index 6bcb64879..b29839163 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityJwksService.cs @@ -1,181 +1,183 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Globalization; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StellaOps.Configuration; -using StellaOps.Cryptography; - -namespace StellaOps.Authority.Signing; - -internal sealed class AuthorityJwksService -{ - private const string CacheKey = "authority:jwks:current"; - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private readonly ICryptoProviderRegistry registry; - private readonly ILogger logger; - private readonly IMemoryCache cache; - private readonly TimeProvider timeProvider; - private readonly StellaOpsAuthorityOptions authorityOptions; - - public AuthorityJwksService( - ICryptoProviderRegistry registry, - ILogger logger, - IMemoryCache cache, - TimeProvider timeProvider, - IOptions authorityOptions) - { - this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); - this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - if (authorityOptions is null) - { - throw new ArgumentNullException(nameof(authorityOptions)); - } - - this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions)); - } - - public AuthorityJwksResult Get() - { - if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) && - cached is not null && - cached.ExpiresAt > timeProvider.GetUtcNow()) - { - return cached.Result; - } - - var response = new AuthorityJwksResponse(BuildKeys()); - var signingOptions = authorityOptions.Signing; - var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero - ? signingOptions.JwksCacheLifetime - : TimeSpan.FromMinutes(5); - var expires = timeProvider.GetUtcNow().Add(lifetime); - var etag = ComputeEtag(response, expires); - var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}"; - - var result = new AuthorityJwksResult(response, etag, expires, cacheControl); - var entry = new AuthorityJwksCacheEntry(result, expires); - - cache.Set(CacheKey, entry, new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = lifetime - }); - return result; - } - - public void Invalidate() - { - cache.Remove(CacheKey); - } - - private IReadOnlyCollection BuildKeys() - { - var keys = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var provider in registry.Providers) - { - foreach (var signingKey in provider.GetSigningKeys()) - { - var keyId = signingKey.Reference.KeyId; - if (!seen.Add(keyId)) - { - continue; - } - - try - { - var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference); - var jwk = signer.ExportPublicJsonWebKey(); - var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse) - ? metadataUse - : jwk.Use; - - if (string.IsNullOrWhiteSpace(keyUse)) - { - keyUse = "sig"; - } - - var entry = new JwksKeyEntry - { - Kid = jwk.Kid, - Kty = jwk.Kty, - Use = keyUse, - Alg = jwk.Alg, - Crv = jwk.Crv, - X = jwk.X, - Y = jwk.Y, - Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active" - }; - keys.Add(entry); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId); - } - } - } - - keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal)); - return keys; - } - - private static string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt) - { - var payload = JsonSerializer.Serialize(response, SerializerOptions); - var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - var hash = SHA256.HashData(buffer); - return $"\"{Convert.ToHexString(hash)}\""; - } - - private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt); -} - -internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection Keys); - -internal sealed record AuthorityJwksResult( - AuthorityJwksResponse Response, - string ETag, - DateTimeOffset ExpiresAt, - string CacheControl); - -internal sealed class JwksKeyEntry -{ - [JsonPropertyName("kty")] - public string? Kty { get; set; } - - [JsonPropertyName("use")] - public string? Use { get; set; } - - [JsonPropertyName("kid")] - public string? Kid { get; set; } - - [JsonPropertyName("alg")] - public string? Alg { get; set; } - - [JsonPropertyName("crv")] - public string? Crv { get; set; } - - [JsonPropertyName("x")] - public string? X { get; set; } - - [JsonPropertyName("y")] - public string? Y { get; set; } - - [JsonPropertyName("status")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Status { get; set; } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Configuration; +using StellaOps.Cryptography; + +namespace StellaOps.Authority.Signing; + +internal sealed class AuthorityJwksService +{ + private const string CacheKey = "authority:jwks:current"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ICryptoProviderRegistry registry; + private readonly ICryptoHash hash; + private readonly ILogger logger; + private readonly IMemoryCache cache; + private readonly TimeProvider timeProvider; + private readonly StellaOpsAuthorityOptions authorityOptions; + + public AuthorityJwksService( + ICryptoProviderRegistry registry, + ICryptoHash hash, + ILogger logger, + IMemoryCache cache, + TimeProvider timeProvider, + IOptions authorityOptions) + { + this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); + this.hash = hash ?? throw new ArgumentNullException(nameof(hash)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); + this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + if (authorityOptions is null) + { + throw new ArgumentNullException(nameof(authorityOptions)); + } + + this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions)); + } + + public AuthorityJwksResult Get() + { + if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) && + cached is not null && + cached.ExpiresAt > timeProvider.GetUtcNow()) + { + return cached.Result; + } + + var response = new AuthorityJwksResponse(BuildKeys()); + var signingOptions = authorityOptions.Signing; + var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero + ? signingOptions.JwksCacheLifetime + : TimeSpan.FromMinutes(5); + var expires = timeProvider.GetUtcNow().Add(lifetime); + var etag = ComputeEtag(response, expires); + var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}"; + + var result = new AuthorityJwksResult(response, etag, expires, cacheControl); + var entry = new AuthorityJwksCacheEntry(result, expires); + + cache.Set(CacheKey, entry, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = lifetime + }); + return result; + } + + public void Invalidate() + { + cache.Remove(CacheKey); + } + + private IReadOnlyCollection BuildKeys() + { + var keys = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var provider in registry.Providers) + { + foreach (var signingKey in provider.GetSigningKeys()) + { + var keyId = signingKey.Reference.KeyId; + if (!seen.Add(keyId)) + { + continue; + } + + try + { + var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference); + var jwk = signer.ExportPublicJsonWebKey(); + var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse) + ? metadataUse + : jwk.Use; + + if (string.IsNullOrWhiteSpace(keyUse)) + { + keyUse = "sig"; + } + + var entry = new JwksKeyEntry + { + Kid = jwk.Kid, + Kty = jwk.Kty, + Use = keyUse, + Alg = jwk.Alg, + Crv = jwk.Crv, + X = jwk.X, + Y = jwk.Y, + Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active" + }; + keys.Add(entry); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId); + } + } + } + + keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal)); + return keys; + } + + private string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt) + { + var payload = JsonSerializer.Serialize(response, SerializerOptions); + var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + var digest = hash.ComputeHash(buffer, HashAlgorithms.Sha256); + return $"\"{Convert.ToHexString(digest)}\""; + } + + private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt); +} + +internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection Keys); + +internal sealed record AuthorityJwksResult( + AuthorityJwksResponse Response, + string ETag, + DateTimeOffset ExpiresAt, + string CacheControl); + +internal sealed class JwksKeyEntry +{ + [JsonPropertyName("kty")] + public string? Kty { get; set; } + + [JsonPropertyName("use")] + public string? Use { get; set; } + + [JsonPropertyName("kid")] + public string? Kid { get; set; } + + [JsonPropertyName("alg")] + public string? Alg { get; set; } + + [JsonPropertyName("crv")] + public string? Crv { get; set; } + + [JsonPropertyName("x")] + public string? X { get; set; } + + [JsonPropertyName("y")] + public string? Y { get; set; } + + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Status { get; set; } +} diff --git a/src/Authority/StellaOps.Authority/TASKS.md b/src/Authority/StellaOps.Authority/TASKS.md index e1eda1721..281d346b6 100644 --- a/src/Authority/StellaOps.Authority/TASKS.md +++ b/src/Authority/StellaOps.Authority/TASKS.md @@ -1,170 +1,182 @@ -# Authority Host Task Board — Epic 1: Aggregation-Only Contract -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003. -> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests. -> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet`), preventing Authority test suite run; waiting on Concelier fix before rerun. -> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates). -> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients. -> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles. - -## Link-Not-Merge v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation. - -## Policy Engine v2 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass). -> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example. - -## Graph Explorer v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| - -## Policy Engine + Editor v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. | -> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands. -| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. | -> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour. -> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions. - -## Graph & Vuln Explorer v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged. - -## Orchestrator Dashboard - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first. -> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements. -> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions. -> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break. -> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass. -| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. | -> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`. - -## StellaOps Console (Sprint 23) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added. -> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases. -> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task. -> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published. -> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged. - -## Policy Studio (Sprint 27) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue. -| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. | -> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work. -> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references. -| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. | -> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement. - -## Exceptions v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples. -> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates. - -## Reachability v1 - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating. - -## Vulnerability Explorer (Sprint 29) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. | -| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. | -| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. | -> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes. -> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours. - -## Advisory AI (Sprint 31) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. | -| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. | - -## Export Center -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| - -## Notifications Studio -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. | -| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. | -> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement. -| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. | -> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`. - - -## CLI Parity & Task Packs -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. | -> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows. -> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001). -> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`. -| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. | -> Blocked: Task Runner approval APIs (`ORCH-SVC-42-101`, `TASKRUN-42-001`) still outstanding. Pack scope catalog (AUTH-PACKS-41-001) landed 2025-11-04; resume once execution/approval contracts are published. - -## Authority-Backed Scopes & Tenancy (Epic 14) -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter. -| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. | -> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run. -> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence. -> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour. -> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios. -> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows. - -## Observability & Forensics (Epic 15) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. | -| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. | -| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. | - -## Air-Gapped Mode (Epic 16) - -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. | -| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. | -> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`. -> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests). -| AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Awaiting clarified sealed-confirmation contract and configuration structure before implementation. | -> 2025-11-01: AUTH-AIRGAP-57-001 blocked pending guidance on sealed-confirmation contract and configuration expectations before gating changes (Authority Core & Security Guild, DevOps Guild). - -## SDKs & OpenAPI (Epic 17) -| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | -|----|--------|----------|------------|-------------|---------------| -> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs. -> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions. -| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. | -> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour. -| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. | -> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild). +# Authority Host Task Board — Epic 1: Aggregation-Only Contract +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003. +> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests. +> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet`), preventing Authority test suite run; waiting on Concelier fix before rerun. +> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates). +> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients. +> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles. + +| AUTH-CRYPTO-90-001 | DOING (2025-11-08) | Authority Core & Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Migrate signing/key-loading paths (`KmsAuthoritySigningKeySource`, `FileAuthoritySigningKeySource`, `AuthorityJwksService`, secret hashers) to `ICryptoProviderRegistry` so regional bundles can pick `ru.cryptopro.csp` / `ru.pkcs11` providers as defined in `docs/security/crypto-routing-audit-2025-11-07.md`. | All signing + hashing code paths resolve registry providers; Authority config exposes provider selection; JWKS output references sovereign keys; regression tests updated. | + +## Link-Not-Merge v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation. + +## Policy Engine v2 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass). +> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example. + +## Graph Explorer v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| + +## Policy Engine + Editor v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-DPOP-11-001 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce DPoP sender constraints for all Authority token flows (nonce store selection, algorithm allowlist, `cnf.jkt` persistence, structured telemetry). | `/token` enforces configured DPoP policies (nonce, allowed algorithms); cnf claims verified in integration tests; docs/runbooks updated with configuration guidance. | +> 2025-11-07: Joint Authority/DevOps stand-up committed to shipping nonce store + telemetry updates by 2025-11-10; config samples and integration tests being updated in tandem. +| AUTH-MTLS-11-002 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. | +> 2025-11-08: Wiring cert thumbprint persistence + audit hooks now that DPoP nonce enforcement is in place; targeting shared delivery window with DEVOPS-AIRGAP-57-002. +> 2025-11-07: Same stand-up aligned on 2025-11-10 target for mTLS enforcement + JWKS rotation docs so plugin mitigations can unblock. +| AUTH-POLICY-23-001 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-002 | Introduce fine-grained policy scopes (`policy:read`, `policy:author`, `policy:review`, `policy:simulate`, `findings:read`) for CLI/service identities; refresh discovery metadata, issuer templates, and offline defaults. | Scope catalogue and sample configs updated; `policy-cli` seed credentials rotated; docs recorded migration steps. | +| AUTH-POLICY-23-002 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. | +> 2025-11-08: Policy Engine enforces pending_second_approval when dual-control toggles demand it, activation auditor emits structured `policy.activation.*` scopes, and tests cover settings/audits. +> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands. +| AUTH-POLICY-23-003 | DONE (2025-11-08) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. | +> 2025-11-08: Docs refreshed for dual-control activation (console workflow, compliance checklist, sample YAML) and linked to new Policy Engine activation options. +> 2025-11-07: Scope migration landed (AUTH-POLICY-23-001); dual-approval + documentation tasks now waiting on pairing. +> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions. + +## Graph & Vuln Explorer v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged. + +## Orchestrator Dashboard + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first. +> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements. +> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions. +> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break. +> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass. +| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. | +> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`. + +## StellaOps Console (Sprint 23) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added. +> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases. +> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task. +> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published. +> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged. + +## Policy Studio (Sprint 27) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue. +| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. | +> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work. +> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references. +| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. | +> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement. + +## Exceptions v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples. +> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates. + +## Reachability v1 + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating. + +## Vulnerability Explorer (Sprint 29) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. | +| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. | +| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. | +> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes. +> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours. + +## Advisory AI (Sprint 31) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. | +| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. | + +## Export Center +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| + +## Notifications Studio +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. | +| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. | +> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement. +| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. | +> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`. + + +## CLI Parity & Task Packs +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. | +> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows. +> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001). +> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`. +| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. | +> Blocked: ORCH-SVC-42-101 (Orchestrator log streaming/approvals API) still TODO. AUTH-PACKS-41-001 + TASKRUN-42-001 are DONE (2025-11-04); resume once Orchestrator publishes contracts. + +## Authority-Backed Scopes & Tenancy (Epic 14) +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter. +| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. | +> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run. +> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence. +> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour. +> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios. +> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows. + +## Observability & Forensics (Epic 15) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. | +| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. | +| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. | + +## Air-Gapped Mode (Epic 16) + +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. | +| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. | +> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`. +> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests). +| AUTH-AIRGAP-57-001 | DOING (2025-11-08) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Implement Authority-side sealed-mode checks once DevOps publishes sealed CI artefacts + contract (target 2025-11-10). | +> 2025-11-08: Picked up in tandem with DEVOPS-AIRGAP-57-002 — validating sealed confirmation payload + wiring Authority gating tests against ops/devops/sealed-mode-ci artefacts. +> 2025-11-08: `/token`/`/introspect` now reject mTLS-bound tokens without the recorded certificate; `authority_mtls_mismatch_total` metric + docs updated for plugin consumers. +> 2025-11-08: DevOps sealed-mode CI now emits `authority-sealed-ci.json`; ingest that contract next to unblock enforcement switch. + +## SDKs & OpenAPI (Epic 17) +| ID | Status | Owner(s) | Depends on | Description | Exit Criteria | +|----|--------|----------|------------|-------------|---------------| +> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs. +> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions. +| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. | +> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour. +| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. | +> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild). diff --git a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs index 1030e3cb7..4d19d1654 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs @@ -43,6 +43,7 @@ internal static class CommandFactory root.Add(BuildConfigCommand(options)); root.Add(BuildKmsCommand(services, verboseOption, cancellationToken)); root.Add(BuildVulnCommand(services, verboseOption, cancellationToken)); + root.Add(BuildCryptoCommand(services, verboseOption, cancellationToken)); var pluginLogger = loggerFactory.CreateLogger(); var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger); @@ -180,8 +181,8 @@ internal static class CommandFactory return scan; } - private static Command BuildKmsCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) - { + private static Command BuildKmsCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { var kms = new Command("kms", "Manage file-backed signing keys."); var export = new Command("export", "Export key material to a portable bundle."); @@ -381,9 +382,39 @@ internal static class CommandFactory db.Add(fetch); db.Add(merge); - db.Add(export); - return db; - } + db.Add(export); + return db; + } + + private static Command BuildCryptoCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { + var crypto = new Command("crypto", "Inspect StellaOps cryptography providers."); + var providers = new Command("providers", "List registered crypto providers and keys."); + + var jsonOption = new Option("--json") + { + Description = "Emit JSON output." + }; + + var profileOption = new Option("--profile") + { + Description = "Temporarily override the active registry profile when computing provider order." + }; + + providers.Add(jsonOption); + providers.Add(profileOption); + + providers.SetAction((parseResult, _) => + { + var json = parseResult.GetValue(jsonOption); + var verbose = parseResult.GetValue(verboseOption); + var profile = parseResult.GetValue(profileOption); + return CommandHandlers.HandleCryptoProvidersAsync(services, verbose, json, profile, cancellationToken); + }); + + crypto.Add(providers); + return crypto; + } private static Command BuildSourcesCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index b9096585f..c8d9e455e 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -18,7 +18,8 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Spectre.Console; using Spectre.Console.Rendering; using StellaOps.Auth.Client; @@ -28,7 +29,8 @@ using StellaOps.Cli.Services; using StellaOps.Cli.Services.Models; using StellaOps.Cli.Services.Models.AdvisoryAi; using StellaOps.Cli.Telemetry; -using StellaOps.Cryptography; +using StellaOps.Cryptography; +using StellaOps.Cryptography.DependencyInjection; using StellaOps.Cryptography.Kms; namespace StellaOps.Cli.Commands; @@ -6437,35 +6439,223 @@ internal static class CommandHandlers return source; } - private static async Task TriggerJobAsync( - IBackendOperationsClient client, - ILogger logger, - string jobKind, - IDictionary parameters, - CancellationToken cancellationToken) - { - JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false); - if (result.Success) - { - if (!string.IsNullOrWhiteSpace(result.Location)) - { - logger.LogInformation("Job accepted. Track status at {Location}.", result.Location); - } - else if (result.Run is not null) - { - logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status); - } - else - { - logger.LogInformation("Job accepted."); - } - - Environment.ExitCode = 0; - } - else - { - logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message); - Environment.ExitCode = 1; - } - } -} + private static async Task TriggerJobAsync( + IBackendOperationsClient client, + ILogger logger, + string jobKind, + IDictionary parameters, + CancellationToken cancellationToken) + { + JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false); + if (result.Success) + { + if (!string.IsNullOrWhiteSpace(result.Location)) + { + logger.LogInformation("Job accepted. Track status at {Location}.", result.Location); + } + else if (result.Run is not null) + { + logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status); + } + else + { + logger.LogInformation("Job accepted."); + } + + Environment.ExitCode = 0; + } + else + { + logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message); + Environment.ExitCode = 1; + } + } + + public static Task HandleCryptoProvidersAsync( + IServiceProvider services, + bool verbose, + bool jsonOutput, + string? profileOverride, + CancellationToken cancellationToken) + { + using var scope = services.CreateScope(); + var loggerFactory = scope.ServiceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger("crypto-providers"); + var verbosity = scope.ServiceProvider.GetRequiredService(); + var previousLevel = verbosity.MinimumLevel; + verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; + using var activity = CliActivitySource.Instance.StartActivity("cli.crypto.providers", ActivityKind.Internal); + using var duration = CliMetrics.MeasureCommandDuration("crypto providers"); + + try + { + var registry = scope.ServiceProvider.GetService(); + if (registry is null) + { + logger.LogWarning("Crypto provider registry not available in this environment."); + AnsiConsole.MarkupLine("[yellow]Crypto subsystem is not configured in this environment.[/]"); + return Task.CompletedTask; + } + + var optionsMonitor = scope.ServiceProvider.GetService>(); + var registryOptions = optionsMonitor?.CurrentValue ?? new CryptoProviderRegistryOptions(); + var preferredOrder = DeterminePreferredOrder(registryOptions, profileOverride); + var providers = registry.Providers + .Select(provider => new ProviderInfo( + provider.Name, + provider.GetType().FullName ?? provider.GetType().Name, + DescribeProviderKeys(provider).ToList())) + .ToList(); + + if (jsonOutput) + { + var payload = new + { + activeProfile = registryOptions.ActiveProfile, + preferredOrder, + providers = providers.Select(info => new + { + info.Name, + info.Type, + keys = info.Keys.Select(k => new + { + k.KeyId, + k.AlgorithmId, + Metadata = k.Metadata + }) + }) + }; + + Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions + { + WriteIndented = true + })); + Environment.ExitCode = 0; + return Task.CompletedTask; + } + + RenderCryptoProviders(preferredOrder, providers); + Environment.ExitCode = 0; + } + finally + { + verbosity.MinimumLevel = previousLevel; + } + + return Task.CompletedTask; + } + + private static void RenderCryptoProviders( + IReadOnlyList preferredOrder, + IReadOnlyCollection providers) + { + if (preferredOrder.Count > 0) + { + AnsiConsole.MarkupLine("[cyan]Preferred order:[/] {0}", Markup.Escape(string.Join(", ", preferredOrder))); + } + else + { + AnsiConsole.MarkupLine("[yellow]Preferred order is not configured; using registration order.[/]"); + } + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("Provider"); + table.AddColumn("Type"); + table.AddColumn("Keys"); + + foreach (var provider in providers) + { + var keySummary = provider.Keys.Count == 0 + ? "[grey]No signing keys exposed (managed externally).[/]" + : string.Join(Environment.NewLine, provider.Keys.Select(FormatDescriptor)); + + table.AddRow( + Markup.Escape(provider.Name), + Markup.Escape(provider.Type), + keySummary); + } + + AnsiConsole.Write(table); + } + + private static IReadOnlyList DescribeProviderKeys(ICryptoProvider provider) + { + if (provider is ICryptoProviderDiagnostics diagnostics) + { + return diagnostics.DescribeKeys().ToList(); + } + + var signingKeys = provider.GetSigningKeys(); + if (signingKeys.Count == 0) + { + return Array.Empty(); + } + + var descriptors = new List(signingKeys.Count); + foreach (var signingKey in signingKeys) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["kind"] = signingKey.Kind.ToString(), + ["createdAt"] = signingKey.CreatedAt.UtcDateTime.ToString("O"), + ["providerHint"] = signingKey.Reference.ProviderHint + }; + + if (signingKey.ExpiresAt.HasValue) + { + metadata["expiresAt"] = signingKey.ExpiresAt.Value.UtcDateTime.ToString("O"); + } + + foreach (var pair in signingKey.Metadata) + { + metadata[$"meta.{pair.Key}"] = pair.Value; + } + + descriptors.Add(new CryptoProviderKeyDescriptor( + provider.Name, + signingKey.Reference.KeyId, + signingKey.AlgorithmId, + metadata)); + } + + return descriptors; + } + + private static IReadOnlyList DeterminePreferredOrder( + CryptoProviderRegistryOptions? options, + string? overrideProfile) + { + if (options is null) + { + return Array.Empty(); + } + + if (!string.IsNullOrWhiteSpace(overrideProfile) && + options.Profiles.TryGetValue(overrideProfile, out var profile) && + profile.PreferredProviders.Count > 0) + { + return profile.PreferredProviders + .Where(static provider => !string.IsNullOrWhiteSpace(provider)) + .Select(static provider => provider.Trim()) + .ToArray(); + } + + return options.ResolvePreferredProviders(); + } + + private static string FormatDescriptor(CryptoProviderKeyDescriptor descriptor) + { + if (descriptor.Metadata.Count == 0) + { + return $"{Markup.Escape(descriptor.KeyId)} ({Markup.Escape(descriptor.AlgorithmId)})"; + } + + var metadataText = string.Join( + ", ", + descriptor.Metadata.Select(pair => $"{pair.Key}={pair.Value}")); + + return $"{Markup.Escape(descriptor.KeyId)} ({Markup.Escape(descriptor.AlgorithmId)}){Environment.NewLine}[grey]{Markup.Escape(metadataText)}[/]"; + } + + private sealed record ProviderInfo(string Name, string Type, IReadOnlyList Keys); +} diff --git a/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs b/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs index dd7029d11..3cdd0f225 100644 --- a/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs +++ b/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs @@ -1,7 +1,8 @@ -using System; -using System.Collections.Generic; -using StellaOps.Auth.Abstractions; -using System.IO; +using System; +using System.Collections.Generic; +using System.IO; +using StellaOps.Auth.Abstractions; +using StellaOps.Configuration; namespace StellaOps.Cli.Configuration; @@ -31,8 +32,11 @@ public sealed class StellaOpsCliOptions public StellaOpsCliOfflineOptions Offline { get; set; } = new(); - public StellaOpsCliPluginOptions Plugins { get; set; } = new(); -} + public StellaOpsCliPluginOptions Plugins { get; set; } = new(); + + public StellaOpsCryptoOptions Crypto { get; set; } = new(); + +} public sealed class StellaOpsCliAuthorityOptions { @@ -79,15 +83,15 @@ public sealed class StellaOpsCliOfflineOptions public string? MirrorUrl { get; set; } } -public sealed class StellaOpsCliPluginOptions -{ +public sealed class StellaOpsCliPluginOptions +{ public string BaseDirectory { get; set; } = string.Empty; public string Directory { get; set; } = "plugins/cli"; public IList SearchPatterns { get; set; } = new List(); - public IList PluginOrder { get; set; } = new List(); - - public string ManifestSearchPattern { get; set; } = "*.manifest.json"; -} + public IList PluginOrder { get; set; } = new List(); + + public string ManifestSearchPattern { get; set; } = "*.manifest.json"; +} diff --git a/src/Cli/StellaOps.Cli/Program.cs b/src/Cli/StellaOps.Cli/Program.cs index 1372482c9..00777826f 100644 --- a/src/Cli/StellaOps.Cli/Program.cs +++ b/src/Cli/StellaOps.Cli/Program.cs @@ -12,6 +12,7 @@ using StellaOps.Cli.Configuration; using StellaOps.Cli.Services; using StellaOps.Cli.Telemetry; using StellaOps.AirGap.Policy; +using StellaOps.Configuration; namespace StellaOps.Cli; @@ -22,12 +23,14 @@ internal static class Program var (options, configuration) = CliBootstrapper.Build(args); var services = new ServiceCollection(); - services.AddSingleton(configuration); - services.AddSingleton(options); - - var verbosityState = new VerbosityState(); + services.AddSingleton(configuration); + services.AddSingleton(options); + services.AddOptions(); + + var verbosityState = new VerbosityState(); services.AddSingleton(verbosityState); services.AddAirGapEgressPolicy(configuration); + services.AddStellaOpsCrypto(options.Crypto); services.AddLogging(builder => { @@ -168,6 +171,7 @@ internal static class Program finalExit = 130; // Typical POSIX cancellation exit code } - return finalExit; - } -} + return finalExit; + } + +} diff --git a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj index 0a55218f7..3cd353875 100644 --- a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj +++ b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj @@ -41,6 +41,8 @@ + + diff --git a/src/Concelier/StellaOps.Concelier.WebService/Diagnostics/IngestionMetrics.cs b/src/Concelier/StellaOps.Concelier.WebService/Diagnostics/IngestionMetrics.cs index 7bc219ba8..7470eca25 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Diagnostics/IngestionMetrics.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Diagnostics/IngestionMetrics.cs @@ -1,22 +1,36 @@ -using System.Diagnostics.Metrics; - -namespace StellaOps.Concelier.WebService.Diagnostics; - -internal static class IngestionMetrics -{ - internal const string MeterName = "StellaOps.Concelier.WebService.Ingestion"; - - private static readonly Meter Meter = new(MeterName); - - internal static readonly Counter WriteCounter = Meter.CreateCounter( - "ingestion_write_total", - description: "Counts raw advisory ingestion attempts, segmented by tenant, source, and result."); - - internal static readonly Counter ViolationCounter = Meter.CreateCounter( - "aoc_violation_total", - description: "Counts Aggregation-Only Contract violations detected during ingestion."); - - internal static readonly Counter VerificationCounter = Meter.CreateCounter( - "verify_runs_total", - description: "Counts AOC verification runs initiated via the API."); -} +using System.Collections.Generic; +using System.Diagnostics.Metrics; + +namespace StellaOps.Concelier.WebService.Diagnostics; + +internal static class IngestionMetrics +{ + internal const string MeterName = "StellaOps.Concelier.WebService.Ingestion"; + + private static readonly Meter Meter = new(MeterName); + + internal static readonly Counter IngestionWriteCounter = Meter.CreateCounter( + "ingestion_write_total", + unit: "count", + description: "Number of advisory ingestion attempts processed by the web service."); + + internal static readonly Counter VerificationCounter = Meter.CreateCounter( + "verify_runs_total", + unit: "count", + description: "Number of AOC verification requests processed by the web service."); + + internal static KeyValuePair[] BuildWriteTags(string tenant, string source, string result) => + new[] + { + new KeyValuePair("tenant", tenant), + new KeyValuePair("source", source), + new KeyValuePair("result", result), + }; + + internal static KeyValuePair[] BuildVerifyTags(string tenant, string result) => + new[] + { + new KeyValuePair("tenant", tenant), + new KeyValuePair("result", result), + }; +} diff --git a/src/Concelier/StellaOps.Concelier.WebService/Extensions/AdvisoryRawRequestMapper.cs b/src/Concelier/StellaOps.Concelier.WebService/Extensions/AdvisoryRawRequestMapper.cs index 1dc4ffe73..a648dc949 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Extensions/AdvisoryRawRequestMapper.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Extensions/AdvisoryRawRequestMapper.cs @@ -1,9 +1,10 @@ -using System.Collections.Immutable; -using System.Text.Json; -using StellaOps.Concelier.RawModels; -using StellaOps.Concelier.WebService.Contracts; - -namespace StellaOps.Concelier.WebService.Extensions; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text.Json; +using StellaOps.Concelier.RawModels; +using StellaOps.Concelier.WebService.Contracts; + +namespace StellaOps.Concelier.WebService.Extensions; internal static class AdvisoryRawRequestMapper { @@ -14,13 +15,13 @@ internal static class AdvisoryRawRequestMapper ArgumentNullException.ThrowIfNull(timeProvider); var sourceRequest = request.Source ?? throw new ArgumentException("source section is required.", nameof(request)); - var upstreamRequest = request.Upstream ?? throw new ArgumentException("upstream section is required.", nameof(request)); - var contentRequest = request.Content ?? throw new ArgumentException("content section is required.", nameof(request)); - var identifiersRequest = request.Identifiers ?? throw new ArgumentException("identifiers section is required.", nameof(request)); - - var source = new RawSourceMetadata( - sourceRequest.Vendor, - sourceRequest.Connector, + var upstreamRequest = request.Upstream ?? throw new ArgumentException("upstream section is required.", nameof(request)); + var contentRequest = request.Content ?? throw new ArgumentException("content section is required.", nameof(request)); + var identifiersRequest = request.Identifiers ?? throw new ArgumentException("identifiers section is required.", nameof(request)); + + var source = new RawSourceMetadata( + sourceRequest.Vendor, + sourceRequest.Connector, sourceRequest.Version, string.IsNullOrWhiteSpace(sourceRequest.Stream) ? null : sourceRequest.Stream); @@ -33,22 +34,21 @@ internal static class AdvisoryRawRequestMapper string.IsNullOrWhiteSpace(signatureRequest.Certificate) ? null : signatureRequest.Certificate, string.IsNullOrWhiteSpace(signatureRequest.Digest) ? null : signatureRequest.Digest); - var retrievedAt = upstreamRequest.RetrievedAt ?? timeProvider.GetUtcNow(); - - var upstream = new RawUpstreamMetadata( - upstreamRequest.UpstreamId, - string.IsNullOrWhiteSpace(upstreamRequest.DocumentVersion) ? null : upstreamRequest.DocumentVersion, - retrievedAt, - upstreamRequest.ContentHash, - signature, - NormalizeDictionary(upstreamRequest.Provenance)); - - var rawContent = NormalizeRawContent(contentRequest.Raw); - var content = new RawContent( - contentRequest.Format, - string.IsNullOrWhiteSpace(contentRequest.SpecVersion) ? null : contentRequest.SpecVersion, - rawContent, - string.IsNullOrWhiteSpace(contentRequest.Encoding) ? null : contentRequest.Encoding); + var retrievedAt = upstreamRequest.RetrievedAt ?? timeProvider.GetUtcNow(); + var upstream = new RawUpstreamMetadata( + upstreamRequest.UpstreamId, + string.IsNullOrWhiteSpace(upstreamRequest.DocumentVersion) ? null : upstreamRequest.DocumentVersion, + retrievedAt, + upstreamRequest.ContentHash, + signature, + NormalizeDictionary(upstreamRequest.Provenance)); + + var rawContent = NormalizeRawContent(contentRequest.Raw); + var content = new RawContent( + contentRequest.Format, + string.IsNullOrWhiteSpace(contentRequest.SpecVersion) ? null : contentRequest.SpecVersion, + rawContent, + string.IsNullOrWhiteSpace(contentRequest.Encoding) ? null : contentRequest.Encoding); var aliases = NormalizeStrings(identifiersRequest.Aliases); if (aliases.IsDefault) @@ -56,11 +56,15 @@ internal static class AdvisoryRawRequestMapper aliases = ImmutableArray.Empty; } - var identifiers = new RawIdentifiers( - aliases, - identifiersRequest.Primary); - - var linksetRequest = request.Linkset; + var identifiers = new RawIdentifiers( + aliases, + identifiersRequest.Primary); + var advisoryKey = NormalizeAdvisoryKey( + identifiersRequest.Primary, + aliases, + upstreamRequest.UpstreamId); + + var linksetRequest = request.Linkset; var linkset = new RawLinkset { Aliases = NormalizeStrings(linksetRequest?.Aliases), @@ -71,6 +75,8 @@ internal static class AdvisoryRawRequestMapper Notes = NormalizeDictionary(linksetRequest?.Notes) }; + var links = BuildLinks(advisoryKey, aliases, upstreamRequest.UpstreamId); + return new AdvisoryRawDocument( tenant.Trim().ToLowerInvariant(), source, @@ -78,8 +84,8 @@ internal static class AdvisoryRawRequestMapper content, identifiers, linkset, - AdvisoryKey: string.Empty, - Links: ImmutableArray.Empty); + AdvisoryKey: advisoryKey, + Links: links); } internal static ImmutableArray NormalizeStrings(IEnumerable? values) @@ -124,11 +130,11 @@ internal static class AdvisoryRawRequestMapper return builder.ToImmutable(); } - private static ImmutableArray NormalizeReferences(IEnumerable? references) - { - if (references is null) - { - return ImmutableArray.Empty; + private static ImmutableArray NormalizeReferences(IEnumerable? references) + { + if (references is null) + { + return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); @@ -150,10 +156,59 @@ internal static class AdvisoryRawRequestMapper return builder.Count == 0 ? ImmutableArray.Empty : builder.ToImmutable(); } - private static JsonElement NormalizeRawContent(JsonElement element) - { - var json = element.ValueKind == JsonValueKind.Undefined ? "{}" : element.GetRawText(); - using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json); - return document.RootElement.Clone(); - } -} + private static JsonElement NormalizeRawContent(JsonElement element) + { + var json = element.ValueKind == JsonValueKind.Undefined ? "{}" : element.GetRawText(); + using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json); + return document.RootElement.Clone(); + } + + private static string NormalizeAdvisoryKey(string? primaryId, ImmutableArray aliases, string upstreamId) + { + if (!string.IsNullOrWhiteSpace(primaryId)) + { + return primaryId.Trim(); + } + + foreach (var alias in aliases) + { + if (!string.IsNullOrWhiteSpace(alias)) + { + return alias.Trim(); + } + } + + return string.IsNullOrWhiteSpace(upstreamId) ? string.Empty : upstreamId.Trim(); + } + + private static ImmutableArray BuildLinks(string advisoryKey, ImmutableArray aliases, string upstreamId) + { + var builder = ImmutableArray.CreateBuilder(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + void AddLink(string scheme, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + var normalized = value.Trim(); + var key = $"{scheme}:{normalized}"; + if (seen.Add(key)) + { + builder.Add(new RawLink(scheme, normalized)); + } + } + + AddLink("PRIMARY", advisoryKey); + foreach (var alias in aliases) + { + AddLink("ALIAS", alias); + } + + AddLink("UPSTREAM", upstreamId); + + return builder.Count == 0 ? ImmutableArray.Empty : builder.ToImmutable(); + } +} diff --git a/src/Concelier/StellaOps.Concelier.WebService/Extensions/TelemetryExtensions.cs b/src/Concelier/StellaOps.Concelier.WebService/Extensions/TelemetryExtensions.cs index ba27775e5..b37ee8d6b 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Extensions/TelemetryExtensions.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Extensions/TelemetryExtensions.cs @@ -10,10 +10,11 @@ using OpenTelemetry.Trace; using Serilog; using Serilog.Core; using Serilog.Events; -using StellaOps.Concelier.Core.Jobs; -using StellaOps.Concelier.Connector.Common.Telemetry; -using StellaOps.Concelier.WebService.Diagnostics; -using StellaOps.Concelier.WebService.Options; +using StellaOps.Concelier.Core.Jobs; +using StellaOps.Concelier.Connector.Common.Telemetry; +using StellaOps.Concelier.WebService.Diagnostics; +using StellaOps.Concelier.WebService.Options; +using StellaOps.Ingestion.Telemetry; namespace StellaOps.Concelier.WebService.Extensions; @@ -65,13 +66,14 @@ public static class TelemetryExtensions if (telemetry.EnableTracing) { - openTelemetry.WithTracing(tracing => - { - tracing - .AddSource(JobDiagnostics.ActivitySourceName) - .AddSource(SourceDiagnostics.ActivitySourceName) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation(); + openTelemetry.WithTracing(tracing => + { + tracing + .AddSource(JobDiagnostics.ActivitySourceName) + .AddSource(SourceDiagnostics.ActivitySourceName) + .AddSource(IngestionTelemetry.ActivitySourceName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); ConfigureExporters(telemetry, tracing); }); @@ -84,7 +86,7 @@ public static class TelemetryExtensions metrics .AddMeter(JobDiagnostics.MeterName) .AddMeter(SourceDiagnostics.MeterName) - .AddMeter(IngestionMetrics.MeterName) + .AddMeter(IngestionTelemetry.MeterName) .AddMeter("StellaOps.Concelier.Connector.CertBund") .AddMeter("StellaOps.Concelier.Connector.Nvd") .AddMeter("StellaOps.Concelier.Connector.Vndr.Chromium") diff --git a/src/Concelier/StellaOps.Concelier.WebService/Options/ConcelierOptions.cs b/src/Concelier/StellaOps.Concelier.WebService/Options/ConcelierOptions.cs index 4750fed16..dd2fca3be 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Options/ConcelierOptions.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Options/ConcelierOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using StellaOps.Configuration; namespace StellaOps.Concelier.WebService.Options; @@ -19,6 +20,8 @@ public sealed class ConcelierOptions public FeaturesOptions Features { get; set; } = new(); public AdvisoryChunkOptions AdvisoryChunks { get; set; } = new(); + + public StellaOpsCryptoOptions Crypto { get; } = new(); public sealed class StorageOptions { diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index 5830eb4a2..b4f8703f0 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -82,6 +82,8 @@ builder.Services.AddOptions() }) .ValidateOnStart(); +builder.Services.AddStellaOpsCrypto(concelierOptions.Crypto); + builder.ConfigureConcelierTelemetry(concelierOptions); builder.Services.TryAddSingleton(_ => TimeProvider.System); @@ -387,6 +389,14 @@ var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async ( return authorizationError; } + using var ingestScope = logger.BeginScope(new Dictionary(StringComparer.Ordinal) + { + ["tenant"] = tenant, + ["source.vendor"] = ingestRequest.Source.Vendor, + ["upstream.upstreamId"] = ingestRequest.Upstream.UpstreamId, + ["contentHash"] = ingestRequest.Upstream.ContentHash ?? "(null)" + }); + AdvisoryRawDocument document; try { @@ -423,12 +433,12 @@ var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async ( context.Response.Headers.Location = $"/advisories/raw/{Uri.EscapeDataString(result.Record.Id)}"; } - IngestionMetrics.WriteCounter.Add(1, new[] - { - new KeyValuePair("tenant", tenant), - new KeyValuePair("source", result.Record.Document.Source.Vendor), - new KeyValuePair("result", result.Inserted ? "inserted" : "duplicate") - }); + IngestionMetrics.IngestionWriteCounter.Add( + 1, + IngestionMetrics.BuildWriteTags( + tenant, + ingestRequest.Source.Vendor ?? "(unknown)", + result.Inserted ? "inserted" : "duplicate")); return JsonResult(response, statusCode); } @@ -443,12 +453,12 @@ var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async ( string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash, string.Join(',', guardException.Violations.Select(static violation => violation.ErrorCode))); - IngestionMetrics.ViolationCounter.Add(1, new[] - { - new KeyValuePair("tenant", tenant), - new KeyValuePair("source", document.Source.Vendor), - new KeyValuePair("code", guardException.PrimaryErrorCode) - }); + IngestionMetrics.IngestionWriteCounter.Add( + 1, + IngestionMetrics.BuildWriteTags( + tenant, + ingestRequest.Source.Vendor ?? "(unknown)", + "rejected")); return MapAocGuardException(context, guardException); } @@ -467,25 +477,8 @@ advisoryIngestEndpoint.RequireAocGuard(request => return Array.Empty(); } - var linkset = request.Linkset ?? new AdvisoryLinksetRequest( - Array.Empty(), - Array.Empty(), - Array.Empty(), - Array.Empty(), - Array.Empty(), - new Dictionary(StringComparer.Ordinal)); - - var payload = new - { - tenant = "guard-tenant", - source = request.Source, - upstream = request.Upstream, - content = request.Content, - identifiers = request.Identifiers, - linkset - }; - - return new object?[] { payload }; + var guardDocument = AdvisoryRawRequestMapper.Map(request, "guard-tenant", TimeProvider.System); + return new object?[] { guardDocument }; }, guardOptions: advisoryIngestGuardOptions); if (authorityConfigured) @@ -796,11 +789,9 @@ var aocVerifyEndpoint = app.MapPost("/aoc/verify", async ( var verificationOutcome = response.Truncated ? "truncated" : (violationResponses.Length == 0 ? "ok" : "violations"); - IngestionMetrics.VerificationCounter.Add(1, new[] - { - new KeyValuePair("tenant", tenant), - new KeyValuePair("result", verificationOutcome) - }); + IngestionMetrics.VerificationCounter.Add( + 1, + IngestionMetrics.BuildVerifyTags(tenant, verificationOutcome)); return JsonResult(response); }); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Services/OpenApiDiscoveryDocumentProvider.cs b/src/Concelier/StellaOps.Concelier.WebService/Services/OpenApiDiscoveryDocumentProvider.cs index 4412886bb..a4b7ff357 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Services/OpenApiDiscoveryDocumentProvider.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Services/OpenApiDiscoveryDocumentProvider.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -10,6 +9,7 @@ using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; +using StellaOps.Cryptography; namespace StellaOps.Concelier.WebService.Services; @@ -28,14 +28,18 @@ internal sealed class OpenApiDiscoveryDocumentProvider ]; private readonly EndpointDataSource _endpointDataSource; + private readonly ICryptoHash _hash; private readonly object _syncRoot = new(); private string? _cachedDocumentJson; private string? _cachedEtag; - public OpenApiDiscoveryDocumentProvider(EndpointDataSource endpointDataSource) + public OpenApiDiscoveryDocumentProvider( + EndpointDataSource endpointDataSource, + ICryptoHash hash) { - _endpointDataSource = endpointDataSource; + _endpointDataSource = endpointDataSource ?? throw new ArgumentNullException(nameof(endpointDataSource)); + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); } public (string Payload, string ETag) GetDocument() @@ -58,7 +62,7 @@ internal sealed class OpenApiDiscoveryDocumentProvider }); var bytes = Encoding.UTF8.GetBytes(json); - var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + var hash = _hash.ComputeHashHex(bytes); var computedEtag = $"\"{hash}\""; _cachedDocumentJson = json; diff --git a/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj b/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj index ca2c18eb6..084e0bd72 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj +++ b/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj @@ -30,6 +30,8 @@ + + diff --git a/src/Concelier/StellaOps.Concelier.WebService/TASKS.md b/src/Concelier/StellaOps.Concelier.WebService/TASKS.md index 1e92d591b..013e67489 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/TASKS.md +++ b/src/Concelier/StellaOps.Concelier.WebService/TASKS.md @@ -1,94 +1,98 @@ -# TASKS — Epic 1: Aggregation-Only Contract -> **AOC Reminder:** service links and exposes raw data only—no precedence, severity, or hint computation inside Concelier APIs. -| ID | Status | Owner(s) | Depends on | Notes | -|---|---|---|---|---| -> Docs alignment (2025-10-26): Endpoint expectations + scope requirements detailed in `docs/ingestion/aggregation-only-contract.md` and `docs/security/authority-scopes.md`. -> 2025-10-28: Added coverage for pagination, tenancy enforcement, and ingestion/verification metrics; verified guard handling paths end-to-end. -| CONCELIER-WEB-AOC-19-002 `AOC observability` | TODO | Concelier WebService Guild, Observability Guild | CONCELIER-WEB-AOC-19-001 | Emit `ingestion_write_total`, `aoc_violation_total`, latency histograms, and tracing spans (`ingest.fetch/transform/write`, `aoc.guard`). Wire structured logging to include tenant, source vendor, upstream id, and content hash. | -> Docs alignment (2025-10-26): Metrics/traces/log schema in `docs/observability/observability.md`. -| CONCELIER-WEB-AOC-19-003 `Schema/guard unit tests` | TODO | QA Guild | CONCELIER-WEB-AOC-19-001 | Add unit tests covering schema validation failures, forbidden field rejections (`ERR_AOC_001/002/006/007`), idempotent upserts, and supersedes chains using deterministic fixtures. | -> Docs alignment (2025-10-26): Guard rules + error codes documented in AOC reference §5 and CLI guide. -| CONCELIER-WEB-AOC-19-004 `End-to-end ingest verification` | TODO | Concelier WebService Guild, QA Guild | CONCELIER-WEB-AOC-19-003, CONCELIER-CORE-AOC-19-002 | Create integration tests ingesting large advisory batches (cold/warm) validating linkset enrichment, metrics emission, and reproducible outputs. Capture load-test scripts + doc notes for Offline Kit dry runs. | -> Docs alignment (2025-10-26): Offline verification workflow referenced in `docs/deploy/containers.md` §5. - -## Policy Engine v2 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| CONCELIER-POLICY-20-001 `Policy selection endpoints` | TODO | Concelier WebService Guild | WEB-POLICY-20-001, CONCELIER-CORE-AOC-19-004 | Add batch advisory lookup APIs (`/policy/select/advisories`, `/policy/select/vex`) optimized for PURL/ID lists with pagination, tenant scoping, and explain metadata. | - -## StellaOps Console (Sprint 23) - -| ID | Status | Owner(s) | Depends on | Notes | +# TASKS — Epic 1: Aggregation-Only Contract +> **AOC Reminder:** service links and exposes raw data only—no precedence, severity, or hint computation inside Concelier APIs. +| ID | Status | Owner(s) | Depends on | Notes | +|---|---|---|---|---| +> Docs alignment (2025-10-26): Endpoint expectations + scope requirements detailed in `docs/ingestion/aggregation-only-contract.md` and `docs/security/authority-scopes.md`. +> 2025-10-28: Added coverage for pagination, tenancy enforcement, and ingestion/verification metrics; verified guard handling paths end-to-end. +| CONCELIER-WEB-AOC-19-002 `AOC observability` | DONE (2025-11-07) | Concelier WebService Guild, Observability Guild | CONCELIER-WEB-AOC-19-001 | Emit `ingestion_write_total`, `aoc_violation_total`, latency histograms, and tracing spans (`ingest.fetch/transform/write`, `aoc.guard`). Wire structured logging to include tenant, source vendor, upstream id, and content hash. | +> Docs alignment (2025-10-26): Metrics/traces/log schema in `docs/observability/observability.md`. +| CONCELIER-WEB-AOC-19-003 `Schema/guard unit tests` | TODO | QA Guild | CONCELIER-WEB-AOC-19-001 | Add unit tests covering schema validation failures, forbidden field rejections (`ERR_AOC_001/002/006/007`), idempotent upserts, and supersedes chains using deterministic fixtures. | +> Docs alignment (2025-10-26): Guard rules + error codes documented in AOC reference §5 and CLI guide. +| CONCELIER-WEB-AOC-19-004 `End-to-end ingest verification` | TODO | Concelier WebService Guild, QA Guild | CONCELIER-WEB-AOC-19-003, CONCELIER-CORE-AOC-19-002 | Create integration tests ingesting large advisory batches (cold/warm) validating linkset enrichment, metrics emission, and reproducible outputs. Capture load-test scripts + doc notes for Offline Kit dry runs. | +> Docs alignment (2025-10-26): Offline verification workflow referenced in `docs/deploy/containers.md` §5. +| CONCELIER-WEB-AOC-19-005 `Chunk evidence regression` | TODO (2025-11-08) | Concelier WebService Guild, QA Guild | CONCELIER-WEB-AOC-19-002 | Fix `/advisories/{key}/chunks` seeded fixtures so AdvisoryChunksEndpoint tests stop returning 404/not-found when raw documents are pre-populated; ensure Mongo migrations no longer emit “Unable to locate advisory_raw documents” during test boot. | +| CONCELIER-WEB-AOC-19-006 `Allowlist ingest auth parity` | TODO (2025-11-08) | Concelier WebService Guild | CONCELIER-WEB-AOC-19-002 | Align WebService auth defaults with the test tokens so the allowlisted tenant can create an advisory before forbidden tenants are rejected in `AdvisoryIngestEndpoint_RejectsTenantOutsideAllowlist`. | +| CONCELIER-WEB-AOC-19-007 `AOC verify violation codes` | TODO (2025-11-08) | Concelier WebService Guild, QA Guild | CONCELIER-WEB-AOC-19-002 | Update AOC verify logic/fixtures so guard failures produce the expected `ERR_AOC_001` payload (current regression returns `ERR_AOC_004`) while keeping the mapper/guard parity exercised by the new tests. | +| CONCELIER-CRYPTO-90-001 `Crypto provider adoption` | DOING (2025-11-08) | Concelier WebService Guild, Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Route hashing/signing in OpenAPI discovery, Mirror connectors, and RU advisory adapters through `ICryptoProviderRegistry` so RootPack_RU uses CryptoPro/PKCS#11 keys. Reference `docs/security/crypto-routing-audit-2025-11-07.md`. | + +## Policy Engine v2 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| CONCELIER-POLICY-20-001 `Policy selection endpoints` | TODO | Concelier WebService Guild | WEB-POLICY-20-001, CONCELIER-CORE-AOC-19-004 | Add batch advisory lookup APIs (`/policy/select/advisories`, `/policy/select/vex`) optimized for PURL/ID lists with pagination, tenant scoping, and explain metadata. | + +## StellaOps Console (Sprint 23) + +| ID | Status | Owner(s) | Depends on | Notes | |----|--------|----------|------------|-------| | CONCELIER-CONSOLE-23-001 `Advisory aggregation views` | TODO | Concelier WebService Guild, BE-Base Platform Guild | CONCELIER-LNM-21-201, CONCELIER-LNM-21-202 | Expose `/console/advisories` endpoints returning aggregation groups (per linkset) with source chips, provider-reported severity columns (no local consensus), and provenance metadata for Console list + dashboard cards. Support filters by source, ecosystem, published/modified window, tenant enforcement. | -| CONCELIER-CONSOLE-23-002 `Dashboard deltas API` | TODO | Concelier WebService Guild | CONCELIER-CONSOLE-23-001, CONCELIER-LNM-21-203 | Provide aggregated advisory delta counts (new, modified, conflicting) for Console dashboard + live status ticker; emit structured events for queue lag metrics. Ensure deterministic counts across repeated queries. | -| CONCELIER-CONSOLE-23-003 `Search fan-out helpers` | TODO | Concelier WebService Guild | CONCELIER-CONSOLE-23-001 | Deliver fast lookup endpoints for CVE/GHSA/purl search (linksets, observations) returning evidence fragments for Console global search; implement caching + scope guards. | - -## Graph Explorer v1 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| - -## Link-Not-Merge v1 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| CONCELIER-LNM-21-201 `Observation APIs` | TODO | Concelier WebService Guild, BE-Base Platform Guild | CONCELIER-LNM-21-001 | Add REST endpoints for advisory observations (`GET /advisories/observations`) with filters (alias, purl, source), pagination, and tenancy enforcement. | -| CONCELIER-LNM-21-202 `Linkset APIs` | TODO | Concelier WebService Guild | CONCELIER-LNM-21-002, CONCELIER-LNM-21-003 | Implement linkset read/export endpoints (`/advisories/linksets/{id}`, `/advisories/by-purl/{purl}`, `/advisories/linksets/{id}/export`, `/evidence`) with correlation/conflict payloads and `ERR_AGG_*` mapping. | -| CONCELIER-LNM-21-203 `Ingest events` | TODO | Concelier WebService Guild, Platform Events Guild | CONCELIER-LNM-21-005 | Publish NATS/Redis events for new observations/linksets and ensure idempotent consumer contracts; document event schemas. | - -## Graph & Vuln Explorer v1 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| CONCELIER-GRAPH-24-101 `Advisory summary API` | TODO | Concelier WebService Guild | CONCELIER-GRAPH-24-001 | Expose `/advisories/summary` returning raw linkset/observation metadata for overlay services; no derived severity or fix hints. | -| CONCELIER-GRAPH-28-102 `Evidence batch API` | TODO | Concelier WebService Guild | CONCELIER-LNM-21-201 | Add batch fetch for advisory observations/linksets keyed by component sets to feed Graph overlay tooltips efficiently. | - -## VEX Lens (Sprint 30) - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| CONCELIER-VEXLENS-30-001 `Advisory rationale bridges` | TODO | Concelier WebService Guild, VEX Lens Guild | CONCELIER-VULN-29-001, VEXLENS-30-005 | Guarantee advisory key consistency and cross-links for consensus rationale; Label: VEX-Lens. | - -## Vulnerability Explorer (Sprint 29) - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| +| CONCELIER-CONSOLE-23-002 `Dashboard deltas API` | TODO | Concelier WebService Guild | CONCELIER-CONSOLE-23-001, CONCELIER-LNM-21-203 | Provide aggregated advisory delta counts (new, modified, conflicting) for Console dashboard + live status ticker; emit structured events for queue lag metrics. Ensure deterministic counts across repeated queries. | +| CONCELIER-CONSOLE-23-003 `Search fan-out helpers` | TODO | Concelier WebService Guild | CONCELIER-CONSOLE-23-001 | Deliver fast lookup endpoints for CVE/GHSA/purl search (linksets, observations) returning evidence fragments for Console global search; implement caching + scope guards. | + +## Graph Explorer v1 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| + +## Link-Not-Merge v1 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| CONCELIER-LNM-21-201 `Observation APIs` | TODO | Concelier WebService Guild, BE-Base Platform Guild | CONCELIER-LNM-21-001 | Add REST endpoints for advisory observations (`GET /advisories/observations`) with filters (alias, purl, source), pagination, and tenancy enforcement. | +| CONCELIER-LNM-21-202 `Linkset APIs` | TODO | Concelier WebService Guild | CONCELIER-LNM-21-002, CONCELIER-LNM-21-003 | Implement linkset read/export endpoints (`/advisories/linksets/{id}`, `/advisories/by-purl/{purl}`, `/advisories/linksets/{id}/export`, `/evidence`) with correlation/conflict payloads and `ERR_AGG_*` mapping. | +| CONCELIER-LNM-21-203 `Ingest events` | TODO | Concelier WebService Guild, Platform Events Guild | CONCELIER-LNM-21-005 | Publish NATS/Redis events for new observations/linksets and ensure idempotent consumer contracts; document event schemas. | + +## Graph & Vuln Explorer v1 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| CONCELIER-GRAPH-24-101 `Advisory summary API` | TODO | Concelier WebService Guild | CONCELIER-GRAPH-24-001 | Expose `/advisories/summary` returning raw linkset/observation metadata for overlay services; no derived severity or fix hints. | +| CONCELIER-GRAPH-28-102 `Evidence batch API` | TODO | Concelier WebService Guild | CONCELIER-LNM-21-201 | Add batch fetch for advisory observations/linksets keyed by component sets to feed Graph overlay tooltips efficiently. | + +## VEX Lens (Sprint 30) + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| CONCELIER-VEXLENS-30-001 `Advisory rationale bridges` | TODO | Concelier WebService Guild, VEX Lens Guild | CONCELIER-VULN-29-001, VEXLENS-30-005 | Guarantee advisory key consistency and cross-links for consensus rationale; Label: VEX-Lens. | + +## Vulnerability Explorer (Sprint 29) + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| | CONCELIER-VULN-29-001 `Advisory key canonicalization` | DONE (2025-11-07) | Concelier WebService Guild, Data Integrity Guild | CONCELIER-LNM-21-001 | Canonicalize (lossless) advisory identifiers (CVE/GHSA/vendor) into `advisory_key`, persist `links[]`, expose raw payload snapshots for Explorer evidence tabs; AOC-compliant: no merge, no derived fields, no suppression. Include migration/backfill scripts. | | CONCELIER-VULN-29-002 `Evidence retrieval API` | DOING (2025-11-07) | Concelier WebService Guild | CONCELIER-VULN-29-001, VULN-API-29-003 | Provide `/vuln/evidence/advisories/{advisory_key}` returning raw advisory docs with provenance, filtering by tenant and source. | | CONCELIER-VULN-29-004 `Observability enhancements` | TODO | Concelier WebService Guild, Observability Guild | CONCELIER-VULN-29-001 | Instrument metrics/logs for observation + linkset pipelines (identifier collisions, withdrawn flags) and emit events consumed by Vuln Explorer resolver. | - -## Advisory AI (Sprint 31) - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| + +## Advisory AI (Sprint 31) + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| | CONCELIER-AIAI-31-001 `Paragraph anchors` | DONE | Concelier WebService Guild | CONCELIER-VULN-29-001 | Expose advisory chunk API returning paragraph anchors, section metadata, and token-safe text for Advisory AI retrieval. See docs/updates/2025-11-07-concelier-advisory-chunks.md. | | CONCELIER-AIAI-31-002 `Structured fields` | TODO | Concelier WebService Guild | CONCELIER-AIAI-31-001 | Ensure observation APIs expose upstream workaround/fix/CVSS fields with provenance; add caching for summary queries. | -| CONCELIER-AIAI-31-003 `Advisory AI telemetry` | TODO | Concelier WebService Guild, Observability Guild | CONCELIER-AIAI-31-001 | Emit metrics/logs for chunk requests, cache hits, and guardrail blocks triggered by advisory payloads. | - -## Observability & Forensics (Epic 15) -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| CONCELIER-WEB-OBS-50-001 `Telemetry adoption` | TODO | Concelier WebService Guild | TELEMETRY-OBS-50-001, CONCELIER-OBS-50-001 | Adopt telemetry core in web service host, ensure ingest + read endpoints emit trace/log fields (`tenant_id`, `route`, `decision_effect`), and add correlation IDs to responses. | -| CONCELIER-WEB-OBS-51-001 `Observability APIs` | TODO | Concelier WebService Guild | CONCELIER-WEB-OBS-50-001, WEB-OBS-51-001 | Surface ingest health metrics, queue depth, and SLO status via `/obs/concelier/health` endpoint for Console widgets, with caching and tenant partitioning. | -| CONCELIER-WEB-OBS-52-001 `Timeline streaming` | TODO | Concelier WebService Guild | CONCELIER-WEB-OBS-50-001, TIMELINE-OBS-52-003 | Provide SSE stream `/obs/concelier/timeline` bridging to Timeline Indexer with paging tokens, guardrails, and audit logging. | -| CONCELIER-WEB-OBS-53-001 `Evidence locker integration` | TODO | Concelier WebService Guild, Evidence Locker Guild | CONCELIER-OBS-53-001, EVID-OBS-53-003 | Add `/evidence/advisories/*` routes invoking evidence locker snapshots, verifying tenant scopes (`evidence:read`), and returning signed manifest metadata. | -| CONCELIER-WEB-OBS-54-001 `Attestation exposure` | TODO | Concelier WebService Guild | CONCELIER-OBS-54-001, PROV-OBS-54-001 | Provide `/attestations/advisories/*` read APIs surfacing DSSE status, verification summary, and provenance chain for Console/CLI. | -| CONCELIER-WEB-OBS-55-001 `Incident mode toggles` | TODO | Concelier WebService Guild, DevOps Guild | CONCELIER-OBS-55-001, WEB-OBS-55-001 | Implement incident mode toggle endpoints, propagate to orchestrator/locker, and document cooldown/backoff semantics. | - -## Air-Gapped Mode (Epic 16) -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| CONCELIER-WEB-AIRGAP-56-001 `Mirror import APIs` | TODO | Concelier WebService Guild | AIRGAP-IMP-58-001, CONCELIER-AIRGAP-56-001 | Extend ingestion endpoints to register mirror bundle sources, expose bundle catalog queries, and block external feed URLs in sealed mode. | -| CONCELIER-WEB-AIRGAP-56-002 `Airgap status surfaces` | TODO | Concelier WebService Guild | CONCELIER-AIRGAP-57-002, AIRGAP-CTL-56-002 | Add staleness metadata and bundle provenance to advisory APIs (`/advisories/observations`, `/advisories/linksets`). | -| CONCELIER-WEB-AIRGAP-57-001 `Error remediation` | TODO | Concelier WebService Guild, AirGap Policy Guild | AIRGAP-POL-56-001 | Map sealed-mode violations to `AIRGAP_EGRESS_BLOCKED` responses with user guidance. | -| CONCELIER-WEB-AIRGAP-58-001 `Import timeline emission` | TODO | Concelier WebService Guild, AirGap Importer Guild | CONCELIER-WEB-AIRGAP-56-001, TIMELINE-OBS-53-001 | Emit timeline events for bundle ingestion operations with bundle ID, scope, and actor metadata. | - -## SDKs & OpenAPI (Epic 17) -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| +| CONCELIER-AIAI-31-003 `Advisory AI telemetry` | TODO | Concelier WebService Guild, Observability Guild | CONCELIER-AIAI-31-001 | Emit metrics/logs for chunk requests, cache hits, and guardrail blocks triggered by advisory payloads. | + +## Observability & Forensics (Epic 15) +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| CONCELIER-WEB-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Concelier WebService Guild | TELEMETRY-OBS-50-001, CONCELIER-OBS-50-001 | Adopt telemetry core in web service host, ensure ingest + read endpoints emit trace/log fields (`tenant_id`, `route`, `decision_effect`), and add correlation IDs to responses. | +| CONCELIER-WEB-OBS-51-001 `Observability APIs` | TODO | Concelier WebService Guild | CONCELIER-WEB-OBS-50-001, WEB-OBS-51-001 | Surface ingest health metrics, queue depth, and SLO status via `/obs/concelier/health` endpoint for Console widgets, with caching and tenant partitioning. | +| CONCELIER-WEB-OBS-52-001 `Timeline streaming` | TODO | Concelier WebService Guild | CONCELIER-WEB-OBS-50-001, TIMELINE-OBS-52-003 | Provide SSE stream `/obs/concelier/timeline` bridging to Timeline Indexer with paging tokens, guardrails, and audit logging. | +| CONCELIER-WEB-OBS-53-001 `Evidence locker integration` | TODO | Concelier WebService Guild, Evidence Locker Guild | CONCELIER-OBS-53-001, EVID-OBS-53-003 | Add `/evidence/advisories/*` routes invoking evidence locker snapshots, verifying tenant scopes (`evidence:read`), and returning signed manifest metadata. | +| CONCELIER-WEB-OBS-54-001 `Attestation exposure` | TODO | Concelier WebService Guild | CONCELIER-OBS-54-001, PROV-OBS-54-001 | Provide `/attestations/advisories/*` read APIs surfacing DSSE status, verification summary, and provenance chain for Console/CLI. | +| CONCELIER-WEB-OBS-55-001 `Incident mode toggles` | TODO | Concelier WebService Guild, DevOps Guild | CONCELIER-OBS-55-001, WEB-OBS-55-001 | Implement incident mode toggle endpoints, propagate to orchestrator/locker, and document cooldown/backoff semantics. | + +## Air-Gapped Mode (Epic 16) +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| CONCELIER-WEB-AIRGAP-56-001 `Mirror import APIs` | TODO | Concelier WebService Guild | AIRGAP-IMP-58-001, CONCELIER-AIRGAP-56-001 | Extend ingestion endpoints to register mirror bundle sources, expose bundle catalog queries, and block external feed URLs in sealed mode. | +| CONCELIER-WEB-AIRGAP-56-002 `Airgap status surfaces` | TODO | Concelier WebService Guild | CONCELIER-AIRGAP-57-002, AIRGAP-CTL-56-002 | Add staleness metadata and bundle provenance to advisory APIs (`/advisories/observations`, `/advisories/linksets`). | +| CONCELIER-WEB-AIRGAP-57-001 `Error remediation` | TODO | Concelier WebService Guild, AirGap Policy Guild | AIRGAP-POL-56-001 | Map sealed-mode violations to `AIRGAP_EGRESS_BLOCKED` responses with user guidance. | +| CONCELIER-WEB-AIRGAP-58-001 `Import timeline emission` | TODO | Concelier WebService Guild, AirGap Importer Guild | CONCELIER-WEB-AIRGAP-56-001, TIMELINE-OBS-53-001 | Emit timeline events for bundle ingestion operations with bundle ID, scope, and actor metadata. | + +## SDKs & OpenAPI (Epic 17) +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| | CONCELIER-WEB-OAS-61-001 `/.well-known/openapi` | DONE (2025-11-02) | Concelier WebService Guild | OAS-61-001 | Implement discovery endpoint emitting Concelier spec with version metadata and ETag. | -| CONCELIER-WEB-OAS-61-002 `Error envelope migration` | TODO | Concelier WebService Guild | APIGOV-61-001 | Ensure all API responses use standardized error envelope; update controllers/tests. | -| CONCELIER-WEB-OAS-62-001 `Examples expansion` | TODO | Concelier WebService Guild | CONCELIER-OAS-61-002 | Add curated examples for advisory observations/linksets/conflicts; integrate into dev portal. | -| CONCELIER-WEB-OAS-63-001 `Deprecation headers` | TODO | Concelier WebService Guild, API Governance Guild | APIGOV-63-001 | Add Sunset/Deprecation headers for retiring endpoints and update documentation/notifications. | +| CONCELIER-WEB-OAS-61-002 `Error envelope migration` | TODO | Concelier WebService Guild | APIGOV-61-001 | Ensure all API responses use standardized error envelope; update controllers/tests. | +| CONCELIER-WEB-OAS-62-001 `Examples expansion` | TODO | Concelier WebService Guild | CONCELIER-OAS-61-002 | Add curated examples for advisory observations/linksets/conflicts; integrate into dev portal. | +| CONCELIER-WEB-OAS-63-001 `Deprecation headers` | TODO | Concelier WebService Guild, API Governance Guild | APIGOV-63-001 | Add Sunset/Deprecation headers for retiring endpoints and update documentation/notifications. | diff --git a/src/Concelier/StellaOps.Concelier.sln b/src/Concelier/StellaOps.Concelier.sln index 3f126d545..5467b0da9 100644 --- a/src/Concelier/StellaOps.Concelier.sln +++ b/src/Concelier/StellaOps.Concelier.sln @@ -187,6 +187,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Analyzers", "__Analyzers" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Analyzers", "__Analyzers\StellaOps.Concelier.Analyzers\StellaOps.Concelier.Analyzers.csproj", "{39C1D44C-389F-4502-ADCF-E4AC359E8F8F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "..\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1265,6 +1267,18 @@ Global {39C1D44C-389F-4502-ADCF-E4AC359E8F8F}.Release|x64.Build.0 = Release|Any CPU {39C1D44C-389F-4502-ADCF-E4AC359E8F8F}.Release|x86.ActiveCfg = Release|Any CPU {39C1D44C-389F-4502-ADCF-E4AC359E8F8F}.Release|x86.Build.0 = Release|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Debug|x64.ActiveCfg = Debug|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Debug|x64.Build.0 = Debug|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Debug|x86.ActiveCfg = Debug|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Debug|x86.Build.0 = Debug|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Release|Any CPU.Build.0 = Release|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Release|x64.ActiveCfg = Release|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Release|x64.Build.0 = Release|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Release|x86.ActiveCfg = Release|Any CPU + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1349,5 +1363,6 @@ Global {9006A5A2-01D8-4A70-AEA7-B7B1987C4A62} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642} {664A2577-6DA1-42DA-A213-3253017FA4BF} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642} {39C1D44C-389F-4502-ADCF-E4AC359E8F8F} = {176B5A8A-7857-3ECD-1128-3C721BC7F5C6} + {85D215EC-DCFE-4F7F-BB07-540DCF66BE8C} = {41F15E67-7190-CF23-3BC4-77E87134CADD} EndGlobalSection EndGlobal diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/CccsConnector.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/CccsConnector.cs index 39c4ef2e9..2ddcbb14d 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/CccsConnector.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/CccsConnector.cs @@ -25,17 +25,18 @@ namespace StellaOps.Concelier.Connector.Cccs; public sealed class CccsConnector : IFeedConnector { - private static readonly JsonSerializerOptions RawSerializerOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private static readonly JsonSerializerOptions DtoSerializerOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private const string DtoSchemaVersion = "cccs.dto.v1"; + private static readonly JsonSerializerOptions RawSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static readonly JsonSerializerOptions DtoSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static readonly Uri CanonicalBaseUri = new("https://www.cyber.gc.ca", UriKind.Absolute); + private const string DtoSchemaVersion = "cccs.dto.v1"; private readonly CccsFeedClient _feedClient; private readonly RawDocumentStorage _rawDocumentStorage; @@ -482,24 +483,37 @@ public sealed class CccsConnector : IFeedConnector } } - private static string BuildDocumentUri(CccsFeedItem item, CccsFeedEndpoint feed) - { - if (!string.IsNullOrWhiteSpace(item.Url)) - { - if (Uri.TryCreate(item.Url, UriKind.Absolute, out var absolute)) - { - return absolute.ToString(); - } - - var baseUri = new Uri("https://www.cyber.gc.ca", UriKind.Absolute); - if (Uri.TryCreate(baseUri, item.Url, out var combined)) - { - return combined.ToString(); - } - } - - return $"https://www.cyber.gc.ca/api/cccs/threats/{feed.Language}/{item.Nid}"; - } + private static string BuildDocumentUri(CccsFeedItem item, CccsFeedEndpoint feed) + { + var candidate = item.Url?.Trim(); + if (!string.IsNullOrWhiteSpace(candidate)) + { + if (Uri.TryCreate(candidate, UriKind.Absolute, out var absolute)) + { + if (IsHttpScheme(absolute.Scheme)) + { + return absolute.ToString(); + } + + candidate = absolute.PathAndQuery; + if (!string.IsNullOrEmpty(absolute.Fragment)) + { + candidate += absolute.Fragment; + } + } + + if (!string.IsNullOrWhiteSpace(candidate) && Uri.TryCreate(CanonicalBaseUri, candidate, out var combined)) + { + return combined.ToString(); + } + } + + return new Uri(CanonicalBaseUri, $"/api/cccs/threats/{feed.Language}/{item.Nid}").ToString(); + } + + private static bool IsHttpScheme(string? scheme) + => string.Equals(scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); private static CccsRawAdvisoryDocument CreateRawDocument(CccsFeedItem item, CccsFeedEndpoint feed, IReadOnlyDictionary taxonomy) { diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Configuration/CccsOptions.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Configuration/CccsOptions.cs index 321af8f40..c10326e67 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Configuration/CccsOptions.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Configuration/CccsOptions.cs @@ -125,11 +125,16 @@ public sealed class CccsFeedEndpoint throw new InvalidOperationException("Feed endpoint URI must be configured before building taxonomy URI."); } - var language = Uri.GetQueryParameterValueOrDefault("lang", Language); - var builder = $"https://www.cyber.gc.ca/api/cccs/taxonomy/v1/get?lang={language}&vocabulary=cccs_alert_type"; - return new Uri(builder, UriKind.Absolute); - } -} + var language = Uri.GetQueryParameterValueOrDefault("lang", Language); + var taxonomyBuilder = new UriBuilder(Uri) + { + Path = "/api/cccs/taxonomy/v1/get", + Query = $"lang={language}&vocabulary=cccs_alert_type" + }; + + return taxonomyBuilder.Uri; + } +} internal static class CccsUriExtensions { diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsHtmlParser.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsHtmlParser.cs index 82f948c83..6fb1fa7d7 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsHtmlParser.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Cccs/Internal/CccsHtmlParser.cs @@ -348,19 +348,21 @@ public sealed class CccsHtmlParser private static string? NormalizeReferenceUrl(string? href, Uri? baseUri, string language) { - if (string.IsNullOrWhiteSpace(href)) - { - return null; - } - - if (!Uri.TryCreate(href, UriKind.Absolute, out var absolute)) - { - if (baseUri is null || !Uri.TryCreate(baseUri, href, out absolute)) - { - return null; - } - } - + if (string.IsNullOrWhiteSpace(href)) + { + return null; + } + + var candidate = href.Trim(); + var hasAbsolute = Uri.TryCreate(candidate, UriKind.Absolute, out var absolute); + if (!hasAbsolute || string.Equals(absolute.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + if (baseUri is null || !Uri.TryCreate(baseUri, candidate, out absolute)) + { + return null; + } + } + var builder = new UriBuilder(absolute) { Fragment = string.Empty, diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Kisa/Internal/KisaDetailParser.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Kisa/Internal/KisaDetailParser.cs index 17dadb89b..66e0d4e34 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Kisa/Internal/KisaDetailParser.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Kisa/Internal/KisaDetailParser.cs @@ -319,12 +319,19 @@ public sealed class KisaDetailParser } var headerRow = labelCell.ParentElement as IHtmlTableRowElement; - var columnIndex = labelCell.CellIndex; + var columnIndex = headerRow is null + ? -1 + : Array.FindIndex(headerRow.Cells.ToArray(), cell => ReferenceEquals(cell, labelCell)); if (headerRow is null) { return null; } + if (columnIndex < 0) + { + return null; + } + var rows = ownerTable.Rows.ToArray(); var headerIndex = Array.FindIndex(rows, row => ReferenceEquals(row, headerRow)); if (headerIndex < 0) diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/RuBduConnector.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/RuBduConnector.cs index 7d4394fa2..b1cb9f88d 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/RuBduConnector.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/RuBduConnector.cs @@ -2,10 +2,9 @@ using System.Collections.Immutable; using System.Globalization; using System.IO; using System.IO.Compression; -using System.Security.Cryptography; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Xml; using System.Xml.Linq; using Microsoft.Extensions.Logging; @@ -17,10 +16,11 @@ using StellaOps.Concelier.Connector.Common.Fetch; using StellaOps.Concelier.Connector.Ru.Bdu.Configuration; using StellaOps.Concelier.Connector.Ru.Bdu.Internal; using StellaOps.Concelier.Storage.Mongo; -using StellaOps.Concelier.Storage.Mongo.Advisories; -using StellaOps.Concelier.Storage.Mongo.Documents; -using StellaOps.Concelier.Storage.Mongo.Dtos; -using StellaOps.Plugin; +using StellaOps.Concelier.Storage.Mongo.Advisories; +using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Storage.Mongo.Dtos; +using StellaOps.Plugin; +using StellaOps.Cryptography; namespace StellaOps.Concelier.Connector.Ru.Bdu; @@ -44,8 +44,9 @@ public sealed class RuBduConnector : IFeedConnector private readonly TimeProvider _timeProvider; private readonly ILogger _logger; - private readonly string _cacheDirectory; - private readonly string _archiveCachePath; + private readonly string _cacheDirectory; + private readonly string _archiveCachePath; + private readonly ICryptoHash _hash; public RuBduConnector( SourceFetchService fetchService, @@ -55,9 +56,10 @@ public sealed class RuBduConnector : IFeedConnector IAdvisoryStore advisoryStore, ISourceStateRepository stateRepository, IOptions options, - RuBduDiagnostics diagnostics, - TimeProvider? timeProvider, - ILogger logger) + RuBduDiagnostics diagnostics, + TimeProvider? timeProvider, + ILogger logger, + ICryptoHash cryptoHash) { _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService)); _rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage)); @@ -69,8 +71,9 @@ public sealed class RuBduConnector : IFeedConnector _options.Validate(); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _timeProvider = timeProvider ?? TimeProvider.System; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _cacheDirectory = ResolveCacheDirectory(_options.CacheDirectory); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _hash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); + _cacheDirectory = ResolveCacheDirectory(_options.CacheDirectory); _archiveCachePath = Path.Combine(_cacheDirectory, "vulxml.zip"); EnsureCacheDirectory(); } @@ -398,7 +401,7 @@ public sealed class RuBduConnector : IFeedConnector } var payload = JsonSerializer.SerializeToUtf8Bytes(dto, SerializerOptions); - var sha = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(); + var sha = _hash.ComputeHashHex(payload); var documentUri = BuildDocumentUri(dto.Identifier); var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, documentUri, cancellationToken).ConfigureAwait(false); diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/StellaOps.Concelier.Connector.Ru.Bdu.csproj b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/StellaOps.Concelier.Connector.Ru.Bdu.csproj index 21e91dc4b..b3de4ef2b 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/StellaOps.Concelier.Connector.Ru.Bdu.csproj +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Bdu/StellaOps.Concelier.Connector.Ru.Bdu.csproj @@ -14,6 +14,7 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/RuNkckiConnector.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/RuNkckiConnector.cs index 4607db0c6..39c75e169 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/RuNkckiConnector.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/RuNkckiConnector.cs @@ -4,23 +4,23 @@ using System.IO; using System.IO.Compression; using System.Net; using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using AngleSharp.Html.Parser; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MongoDB.Bson; -using StellaOps.Concelier.Connector.Common; -using StellaOps.Concelier.Connector.Common.Fetch; -using StellaOps.Concelier.Connector.Ru.Nkcki.Configuration; -using StellaOps.Concelier.Connector.Ru.Nkcki.Internal; -using StellaOps.Concelier.Storage.Mongo; -using StellaOps.Concelier.Storage.Mongo.Advisories; -using StellaOps.Concelier.Storage.Mongo.Documents; -using StellaOps.Concelier.Storage.Mongo.Dtos; -using StellaOps.Plugin; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using AngleSharp.Html.Parser; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using StellaOps.Concelier.Connector.Common; +using StellaOps.Concelier.Connector.Common.Fetch; +using StellaOps.Concelier.Connector.Ru.Nkcki.Configuration; +using StellaOps.Concelier.Connector.Ru.Nkcki.Internal; +using StellaOps.Concelier.Storage.Mongo; +using StellaOps.Concelier.Storage.Mongo.Advisories; +using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Storage.Mongo.Dtos; +using StellaOps.Plugin; +using StellaOps.Cryptography; namespace StellaOps.Concelier.Connector.Ru.Nkcki; @@ -55,11 +55,12 @@ public sealed class RuNkckiConnector : IFeedConnector private readonly ISourceStateRepository _stateRepository; private readonly RuNkckiOptions _options; private readonly TimeProvider _timeProvider; - private readonly RuNkckiDiagnostics _diagnostics; - private readonly ILogger _logger; - private readonly string _cacheDirectory; - - private readonly HtmlParser _htmlParser = new(); + private readonly RuNkckiDiagnostics _diagnostics; + private readonly ILogger _logger; + private readonly string _cacheDirectory; + private readonly ICryptoHash _hash; + + private readonly HtmlParser _htmlParser = new(); public RuNkckiConnector( SourceFetchService fetchService, @@ -69,9 +70,10 @@ public sealed class RuNkckiConnector : IFeedConnector IAdvisoryStore advisoryStore, ISourceStateRepository stateRepository, IOptions options, - RuNkckiDiagnostics diagnostics, - TimeProvider? timeProvider, - ILogger logger) + RuNkckiDiagnostics diagnostics, + TimeProvider? timeProvider, + ILogger logger, + ICryptoHash cryptoHash) { _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService)); _rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage)); @@ -79,12 +81,13 @@ public sealed class RuNkckiConnector : IFeedConnector _dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore)); _advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore)); _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); - _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); - _options.Validate(); - _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); - _timeProvider = timeProvider ?? TimeProvider.System; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _cacheDirectory = ResolveCacheDirectory(_options.CacheDirectory); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); + _options.Validate(); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _hash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); + _cacheDirectory = ResolveCacheDirectory(_options.CacheDirectory); EnsureCacheDirectory(); } @@ -597,7 +600,7 @@ public sealed class RuNkckiConnector : IFeedConnector } var payload = JsonSerializer.SerializeToUtf8Bytes(dto, SerializerOptions); - var sha = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(); + var sha = _hash.ComputeHashHex(payload); var documentUri = BuildDocumentUri(dto); var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, documentUri, cancellationToken).ConfigureAwait(false); diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/StellaOps.Concelier.Connector.Ru.Nkcki.csproj b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/StellaOps.Concelier.Connector.Ru.Nkcki.csproj index ed67e1a96..bba913aff 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/StellaOps.Concelier.Connector.Ru.Nkcki.csproj +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Ru.Nkcki/StellaOps.Concelier.Connector.Ru.Nkcki.csproj @@ -18,6 +18,7 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOps.Concelier.Connector.StellaOpsMirror.csproj b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOps.Concelier.Connector.StellaOpsMirror.csproj index a18fdea7e..5a99ccb8e 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOps.Concelier.Connector.StellaOpsMirror.csproj +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOps.Concelier.Connector.StellaOpsMirror.csproj @@ -8,10 +8,11 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOpsMirrorConnector.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOpsMirrorConnector.cs index 2e896e5f0..8aebee988 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOpsMirrorConnector.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/StellaOpsMirrorConnector.cs @@ -1,11 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Security.Cryptography; -using System.Text; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MongoDB.Bson; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Bson; using StellaOps.Concelier.Connector.Common.Fetch; using StellaOps.Concelier.Connector.Common; using StellaOps.Concelier.Connector.StellaOpsMirror.Client; @@ -15,9 +14,10 @@ using StellaOps.Concelier.Connector.StellaOpsMirror.Settings; using StellaOps.Concelier.Models; using StellaOps.Concelier.Storage.Mongo; using StellaOps.Concelier.Storage.Mongo.Advisories; -using StellaOps.Concelier.Storage.Mongo.Documents; -using StellaOps.Concelier.Storage.Mongo.Dtos; -using StellaOps.Plugin; +using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Storage.Mongo.Dtos; +using StellaOps.Plugin; +using StellaOps.Cryptography; namespace StellaOps.Concelier.Connector.StellaOpsMirror; @@ -30,12 +30,13 @@ public sealed class StellaOpsMirrorConnector : IFeedConnector private readonly MirrorSignatureVerifier _signatureVerifier; private readonly RawDocumentStorage _rawDocumentStorage; private readonly IDocumentStore _documentStore; - private readonly IDtoStore _dtoStore; - private readonly IAdvisoryStore _advisoryStore; - private readonly ISourceStateRepository _stateRepository; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - private readonly StellaOpsMirrorConnectorOptions _options; + private readonly IDtoStore _dtoStore; + private readonly IAdvisoryStore _advisoryStore; + private readonly ISourceStateRepository _stateRepository; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly StellaOpsMirrorConnectorOptions _options; + private readonly ICryptoHash _hash; public StellaOpsMirrorConnector( MirrorManifestClient client, @@ -45,20 +46,22 @@ public sealed class StellaOpsMirrorConnector : IFeedConnector IDtoStore dtoStore, IAdvisoryStore advisoryStore, ISourceStateRepository stateRepository, - IOptions options, - TimeProvider? timeProvider, - ILogger logger) + IOptions options, + TimeProvider? timeProvider, + ICryptoHash cryptoHash, + ILogger logger) { _client = client ?? throw new ArgumentNullException(nameof(client)); _signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier)); _rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage)); _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); _dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore)); - _advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore)); - _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _timeProvider = timeProvider ?? TimeProvider.System; - _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); + _advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore)); + _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; + _hash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); ValidateOptions(_options); } @@ -280,7 +283,7 @@ public sealed class StellaOpsMirrorConnector : IFeedConnector await _stateRepository.UpdateCursorAsync(Source, document, now, cancellationToken).ConfigureAwait(false); } - private static void VerifyDigest(string expected, ReadOnlySpan payload, string path) + private void VerifyDigest(string expected, ReadOnlySpan payload, string path) { if (string.IsNullOrWhiteSpace(expected)) { @@ -292,19 +295,16 @@ public sealed class StellaOpsMirrorConnector : IFeedConnector throw new InvalidOperationException($"Unsupported digest '{expected}' for '{path}'."); } - var actualHash = SHA256.HashData(payload); - var actual = "sha256:" + Convert.ToHexString(actualHash).ToLowerInvariant(); + var actualHash = _hash.ComputeHashHex(payload, HashAlgorithms.Sha256); + var actual = "sha256:" + actualHash; if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"Digest mismatch for '{path}'. Expected {expected}, computed {actual}."); } } - private static string ComputeSha256(ReadOnlySpan payload) - { - var hash = SHA256.HashData(payload); - return Convert.ToHexString(hash).ToLowerInvariant(); - } + private string ComputeSha256(ReadOnlySpan payload) + => _hash.ComputeHashHex(payload, HashAlgorithms.Sha256); private static string NormalizeDigest(string digest) { diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoMapper.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoMapper.cs index 5477b59bc..778ae0c86 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoMapper.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoMapper.cs @@ -5,6 +5,7 @@ using StellaOps.Concelier.Models; using StellaOps.Concelier.Connector.Common.Packages; using StellaOps.Concelier.Storage.Mongo.Documents; using StellaOps.Concelier.Storage.Mongo.Dtos; +using StellaOps.Concelier.Normalization.SemVer; namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; @@ -142,8 +143,9 @@ public static class CiscoMapper continue; } - var range = BuildVersionRange(product, recordedAt); + var ranges = BuildVersionRanges(product, recordedAt); var statuses = BuildStatuses(product, recordedAt); + var normalizedVersions = BuildNormalizedVersions(product, ranges); var provenance = new[] { new AdvisoryProvenance( @@ -157,10 +159,10 @@ public static class CiscoMapper type: AffectedPackageTypes.Vendor, identifier: product.Name, platform: null, - versionRanges: range is null ? Array.Empty() : new[] { range }, + versionRanges: ranges, statuses: statuses, provenance: provenance, - normalizedVersions: Array.Empty())); + normalizedVersions: normalizedVersions)); } return packages.Count == 0 @@ -168,14 +170,46 @@ public static class CiscoMapper : packages.OrderBy(static p => p.Identifier, StringComparer.OrdinalIgnoreCase).ToArray(); } - private static AffectedVersionRange? BuildVersionRange(CiscoAffectedProductDto product, DateTimeOffset recordedAt) + private static IReadOnlyList BuildVersionRanges(CiscoAffectedProductDto product, DateTimeOffset recordedAt) { if (string.IsNullOrWhiteSpace(product.Version)) { - return null; + return Array.Empty(); } var version = product.Version.Trim(); + var provenance = new AdvisoryProvenance( + VndrCiscoConnectorPlugin.SourceName, + "range", + product.ProductId ?? product.Name, + recordedAt); + var vendorExtensions = BuildVendorExtensions(product, includeVersion: true); + + var semVerResults = SemVerRangeRuleBuilder.Build(version, patchedVersion: null, provenanceNote: BuildNormalizedVersionNote(product)); + if (semVerResults.Count > 0) + { + var ranges = new List(semVerResults.Count); + foreach (var result in semVerResults) + { + var semVerPrimitives = new RangePrimitives( + SemVer: result.Primitive, + Nevra: null, + Evr: null, + VendorExtensions: vendorExtensions); + + ranges.Add(new AffectedVersionRange( + rangeKind: NormalizedVersionSchemes.SemVer, + introducedVersion: result.Primitive.Introduced, + fixedVersion: result.Primitive.Fixed, + lastAffectedVersion: result.Primitive.LastAffected, + rangeExpression: result.Expression ?? version, + provenance: provenance, + primitives: semVerPrimitives)); + } + + return ranges; + } + RangePrimitives? primitives = null; string rangeKind = "vendor"; string? rangeExpression = version; @@ -198,23 +232,20 @@ public static class CiscoMapper } else { - primitives = new RangePrimitives(null, null, null, BuildVendorExtensions(product, includeVersion: true)); + primitives = new RangePrimitives(null, null, null, vendorExtensions); } - var provenance = new AdvisoryProvenance( - VndrCiscoConnectorPlugin.SourceName, - "range", - product.ProductId ?? product.Name, - recordedAt); - - return new AffectedVersionRange( + return new[] + { + new AffectedVersionRange( rangeKind: rangeKind, introducedVersion: null, fixedVersion: null, lastAffectedVersion: null, rangeExpression: rangeExpression, provenance: provenance, - primitives: primitives); + primitives: primitives), + }; } private static IReadOnlyDictionary? BuildVendorExtensions(CiscoAffectedProductDto product, bool includeVersion = false) @@ -233,6 +264,48 @@ public static class CiscoMapper return dictionary.Count == 0 ? null : dictionary; } + private static IReadOnlyList BuildNormalizedVersions( + CiscoAffectedProductDto product, + IReadOnlyList ranges) + { + if (ranges.Count == 0) + { + return Array.Empty(); + } + + var note = BuildNormalizedVersionNote(product); + var rules = new List(ranges.Count); + foreach (var range in ranges) + { + var rule = range.ToNormalizedVersionRule(note); + if (rule is not null) + { + rules.Add(rule); + } + } + + return rules.Count == 0 ? Array.Empty() : rules.ToArray(); + } + + private static string? BuildNormalizedVersionNote(CiscoAffectedProductDto product) + { + if (!string.IsNullOrWhiteSpace(product.ProductId)) + { + return $"cisco:{product.ProductId.Trim().ToLowerInvariant()}"; + } + + if (!string.IsNullOrWhiteSpace(product.Name)) + { + var normalized = product.Name + .Trim() + .ToLowerInvariant() + .Replace(' ', '-'); + return $"cisco:{normalized}"; + } + + return null; + } + private static IReadOnlyList BuildStatuses(CiscoAffectedProductDto product, DateTimeOffset recordedAt) { if (product.Statuses is null || product.Statuses.Count == 0) diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/TASKS.md index 008cc529e..f1fc291a4 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Connector.Vndr.Cisco/TASKS.md @@ -1,4 +1,4 @@ # TASKS | Task | Owner(s) | Depends on | Notes | |---|---|---|---| -|FEEDCONN-CISCO-02-009 SemVer range provenance|BE-Conn-Cisco|CONCELIER-LNM-21-001|**TODO (due 2025-10-21)** – Emit Cisco SemVer ranges into `advisory_observations.affected.versions[]` with provenance identifiers (`cisco:{productId}`) and deterministic comparison keys. Update mapper/tests for the Link-Not-Merge schema and replace legacy merge counter checks with observation/linkset validation.| +|FEEDCONN-CISCO-02-009 SemVer range provenance|BE-Conn-Cisco|CONCELIER-LNM-21-001|**DOING (2025-11-08)** – Emitting Cisco SemVer ranges into `advisory_observations.affected.versions[]` with provenance identifiers (`cisco:{productId}`) and deterministic comparison keys. Updating mapper/tests for the Link-Not-Merge schema and replacing legacy merge counter checks with observation/linkset validation.| diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Aoc/AdvisoryRawWriteGuard.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Aoc/AdvisoryRawWriteGuard.cs index 7790e42b9..451857c27 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Aoc/AdvisoryRawWriteGuard.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Aoc/AdvisoryRawWriteGuard.cs @@ -1,35 +1,94 @@ -using System.Text.Json; -using Microsoft.Extensions.Options; -using StellaOps.Aoc; -using StellaOps.Concelier.RawModels; - -namespace StellaOps.Concelier.Core.Aoc; - -/// -/// Aggregation-Only Contract guard applied to raw advisory documents prior to persistence. -/// -public sealed class AdvisoryRawWriteGuard : IAdvisoryRawWriteGuard -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); - - private readonly IAocGuard _guard; - private readonly AocGuardOptions _options; - - public AdvisoryRawWriteGuard(IAocGuard guard, IOptions? options = null) - { - _guard = guard ?? throw new ArgumentNullException(nameof(guard)); - _options = options?.Value ?? AocGuardOptions.Default; - } - - public void EnsureValid(AdvisoryRawDocument document) - { - ArgumentNullException.ThrowIfNull(document); - - using var payload = JsonDocument.Parse(JsonSerializer.Serialize(document, SerializerOptions)); - var result = _guard.Validate(payload.RootElement, _options); - if (!result.IsValid) - { - throw new ConcelierAocGuardException(result); - } - } -} +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Options; +using StellaOps.Aoc; +using StellaOps.Concelier.RawModels; +using StellaOps.Ingestion.Telemetry; + +namespace StellaOps.Concelier.Core.Aoc; + +/// +/// Aggregation-Only Contract guard applied to raw advisory documents prior to persistence. +/// +public sealed class AdvisoryRawWriteGuard : IAdvisoryRawWriteGuard +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + private readonly IAocGuard _guard; + private readonly AocGuardOptions _options; + + public AdvisoryRawWriteGuard(IAocGuard guard, IOptions? options = null) + { + _guard = guard ?? throw new ArgumentNullException(nameof(guard)); + _options = options?.Value ?? AocGuardOptions.Default; + } + + public void EnsureValid(AdvisoryRawDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + var normalized = NormalizeDocument(document); + + var serialized = JsonSerializer.Serialize(normalized, SerializerOptions); + + using var guardActivity = IngestionTelemetry.StartGuardActivity( + normalized.Tenant, + normalized.Source.Vendor, + normalized.Upstream.UpstreamId, + normalized.Upstream.ContentHash, + normalized.Supersedes); + + using var payload = JsonDocument.Parse(serialized); + var result = _guard.Validate(payload.RootElement, _options); + if (!result.IsValid) + { + var violationCount = result.Violations.IsDefaultOrEmpty ? 0 : result.Violations.Length; + var primaryCode = violationCount > 0 ? result.Violations[0].ErrorCode : string.Empty; + + guardActivity?.SetTag("violationCount", violationCount); + if (!string.IsNullOrWhiteSpace(primaryCode)) + { + guardActivity?.SetTag("code", primaryCode); + } + + guardActivity?.SetStatus(ActivityStatusCode.Error, primaryCode); + throw new ConcelierAocGuardException(result); + } + + guardActivity?.SetTag("violationCount", 0); + guardActivity?.SetStatus(ActivityStatusCode.Ok); + } + + private static AdvisoryRawDocument NormalizeDocument(AdvisoryRawDocument document) + { + var identifiers = document.Identifiers with + { + Aliases = Normalize(document.Identifiers.Aliases) + }; + + var linkset = document.Linkset with + { + Aliases = Normalize(document.Linkset.Aliases), + PackageUrls = Normalize(document.Linkset.PackageUrls), + Cpes = Normalize(document.Linkset.Cpes), + References = Normalize(document.Linkset.References), + ReconciledFrom = Normalize(document.Linkset.ReconciledFrom), + Notes = Normalize(document.Linkset.Notes) + }; + + return document with + { + Identifiers = identifiers, + Linkset = linkset, + Links = Normalize(document.Links) + }; + } + + private static ImmutableArray Normalize(ImmutableArray value) => + value.IsDefault ? ImmutableArray.Empty : value; + + private static ImmutableDictionary Normalize(ImmutableDictionary value) + where TKey : notnull => + value == default ? ImmutableDictionary.Empty : value; +} diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Raw/AdvisoryRawService.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Raw/AdvisoryRawService.cs index 8053e5c77..0c21d58b1 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Raw/AdvisoryRawService.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Raw/AdvisoryRawService.cs @@ -1,12 +1,14 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Globalization; -using System.Text; -using System.Text.Json; -using System.Linq; -using Microsoft.Extensions.Logging; -using StellaOps.Aoc; -using StellaOps.Concelier.Core.Aoc; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StellaOps.Aoc; +using StellaOps.Ingestion.Telemetry; +using StellaOps.Concelier.Core.Aoc; using StellaOps.Concelier.Core.Linksets; using StellaOps.Concelier.RawModels; using StellaOps.Concelier.Models; @@ -40,55 +42,104 @@ internal sealed class AdvisoryRawService : IAdvisoryRawService _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(document); - - var clientSupersedes = string.IsNullOrWhiteSpace(document.Supersedes) - ? null - : document.Supersedes.Trim(); - - var normalized = Normalize(document); - var enriched = normalized with { Linkset = _linksetMapper.Map(normalized) }; - - if (!string.IsNullOrEmpty(clientSupersedes)) - { - _logger.LogWarning( - "Ignoring client-supplied supersedes pointer for advisory_raw tenant={Tenant} source={Vendor} upstream={UpstreamId} pointer={Supersedes}", - enriched.Tenant, - enriched.Source.Vendor, - enriched.Upstream.UpstreamId, - clientSupersedes); - } - - _writeGuard.EnsureValid(enriched); - - var result = await _repository.UpsertAsync(enriched, cancellationToken).ConfigureAwait(false); - if (result.Inserted) - { - _logger.LogInformation( - "Ingested advisory_raw document id={DocumentId} tenant={Tenant} source={Vendor} upstream={UpstreamId} hash={Hash} supersedes={Supersedes}", - result.Record.Id, - result.Record.Document.Tenant, - result.Record.Document.Source.Vendor, - result.Record.Document.Upstream.UpstreamId, - result.Record.Document.Upstream.ContentHash, - string.IsNullOrWhiteSpace(result.Record.Document.Supersedes) - ? "(none)" - : result.Record.Document.Supersedes); - } - else - { - _logger.LogDebug( - "Skipped advisory_raw duplicate tenant={Tenant} source={Vendor} upstream={UpstreamId} hash={Hash}", - result.Record.Document.Tenant, - result.Record.Document.Source.Vendor, - result.Record.Document.Upstream.UpstreamId, - result.Record.Document.Upstream.ContentHash); - } - - return result; - } + public async Task IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(document); + + var clientSupersedes = string.IsNullOrWhiteSpace(document.Supersedes) + ? null + : document.Supersedes.Trim(); + + var transformWatch = Stopwatch.StartNew(); + var initialPayloadBytes = EstimatePayloadBytes(document.Content.Raw); + using var transformActivity = IngestionTelemetry.StartTransformActivity( + document.Tenant, + document.Source.Vendor, + document.Upstream.UpstreamId, + document.Upstream.ContentHash, + document.Content.Format, + initialPayloadBytes); + + var normalized = Normalize(document); + var enriched = normalized with { Linkset = _linksetMapper.Map(normalized) }; + transformWatch.Stop(); + + var tenant = enriched.Tenant; + var source = enriched.Source.Vendor; + var upstreamId = enriched.Upstream.UpstreamId; + var contentHash = enriched.Upstream.ContentHash; + + if (!string.IsNullOrEmpty(clientSupersedes)) + { + _logger.LogWarning( + "Ignoring client-supplied supersedes pointer for advisory_raw tenant={Tenant} source={Vendor} upstream={UpstreamId} pointer={Supersedes}", + tenant, + source, + upstreamId, + clientSupersedes); + } + + transformActivity?.SetTag("tenant", tenant); + transformActivity?.SetTag("source", source); + transformActivity?.SetTag("upstream.id", upstreamId); + transformActivity?.SetTag("contentHash", contentHash); + transformActivity?.SetTag("documentType", enriched.Content.Format); + transformActivity?.SetTag("payloadBytes", initialPayloadBytes); + + IngestionTelemetry.RecordLatency(tenant, source, IngestionTelemetry.PhaseTransform, transformWatch.Elapsed); + + try + { + _writeGuard.EnsureValid(enriched); + } + catch (ConcelierAocGuardException guardException) + { + IngestionTelemetry.RecordViolation(tenant, source, guardException.PrimaryErrorCode); + IngestionTelemetry.RecordWriteAttempt(tenant, source, IngestionTelemetry.ResultReject); + throw; + } + + var result = await _repository.UpsertAsync(enriched, cancellationToken).ConfigureAwait(false); + IngestionTelemetry.RecordWriteAttempt(tenant, source, result.Inserted ? IngestionTelemetry.ResultOk : IngestionTelemetry.ResultNoop); + + if (result.Inserted) + { + _logger.LogInformation( + "Ingested advisory_raw document id={DocumentId} tenant={Tenant} source={Vendor} upstream={UpstreamId} hash={Hash} supersedes={Supersedes}", + result.Record.Id, + tenant, + source, + upstreamId, + contentHash, + string.IsNullOrWhiteSpace(result.Record.Document.Supersedes) + ? "(none)" + : result.Record.Document.Supersedes); + } + else + { + _logger.LogDebug( + "Skipped advisory_raw duplicate tenant={Tenant} source={Vendor} upstream={UpstreamId} hash={Hash}", + tenant, + source, + upstreamId, + contentHash); + } + + return result; + } + + private static long EstimatePayloadBytes(JsonElement element) + { + try + { + var text = element.GetRawText(); + return Encoding.UTF8.GetByteCount(text); + } + catch (InvalidOperationException) + { + return 0; + } + } public Task FindByIdAsync(string tenant, string id, CancellationToken cancellationToken) { diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj b/src/Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj index 16d59351d..fa45e5b13 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj @@ -18,7 +18,8 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md index d5e16dd2a..18df78e83 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md @@ -78,7 +78,7 @@ ## Observability & Forensics (Epic 15) | ID | Status | Owner(s) | Depends on | Notes | |----|--------|----------|------------|-------| -| CONCELIER-OBS-50-001 `Telemetry adoption` | TODO | Concelier Core Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Replace ad-hoc logging with telemetry core across ingestion/linking pipelines; ensure spans/logs include tenant, source vendor, upstream id, content hash, and trace IDs. | +| CONCELIER-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Concelier Core Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Replace ad-hoc logging with telemetry core across ingestion/linking pipelines; ensure spans/logs include tenant, source vendor, upstream id, content hash, and trace IDs. | | CONCELIER-OBS-51-001 `Metrics & SLOs` | TODO | Concelier Core Guild, DevOps Guild | CONCELIER-OBS-50-001, TELEMETRY-OBS-51-001 | Emit metrics for ingest latency (cold/warm), queue depth, aoc violation rate, and publish SLO burn-rate alerts (ingest P95 <30s cold / <5s warm). Ship dashboards + alert configs. | | CONCELIER-OBS-52-001 `Timeline events` | TODO | Concelier Core Guild | CONCELIER-OBS-50-001, TIMELINE-OBS-52-002 | Emit `timeline_event` records for advisory ingest/normalization/linkset creation with provenance, trace IDs, conflict summaries, and evidence placeholders. | | CONCELIER-OBS-53-001 `Evidence snapshots` | TODO | Concelier Core Guild, Evidence Locker Guild | CONCELIER-OBS-52-001, EVID-OBS-53-002 | Produce advisory evaluation bundle payloads (raw doc, linkset, normalization diff) for evidence locker; ensure Merkle manifests seeded with content hashes. | diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonExportSnapshotBuilder.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonExportSnapshotBuilder.cs index 49c531cf5..b56bc79be 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonExportSnapshotBuilder.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonExportSnapshotBuilder.cs @@ -1,28 +1,33 @@ using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using StellaOps.Concelier.Models; - -namespace StellaOps.Concelier.Exporter.Json; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using StellaOps.Concelier.Models; +using StellaOps.Cryptography; + +namespace StellaOps.Concelier.Exporter.Json; /// /// Writes canonical advisory snapshots into a vuln-list style directory tree with deterministic ordering. /// public sealed class JsonExportSnapshotBuilder { - private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - private readonly JsonExportOptions _options; - private readonly IJsonExportPathResolver _pathResolver; - - public JsonExportSnapshotBuilder(JsonExportOptions options, IJsonExportPathResolver pathResolver) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); - } + private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + private readonly JsonExportOptions _options; + private readonly IJsonExportPathResolver _pathResolver; + private readonly ICryptoHash _hash; + + public JsonExportSnapshotBuilder( + JsonExportOptions options, + IJsonExportPathResolver pathResolver, + ICryptoHash? hash = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver)); + _hash = hash ?? CryptoHashFactory.CreateDefault(); + } public Task WriteAsync( IReadOnlyCollection advisories, @@ -97,7 +102,7 @@ public sealed class JsonExportSnapshotBuilder await File.WriteAllBytesAsync(destination, bytes, cancellationToken).ConfigureAwait(false); File.SetLastWriteTimeUtc(destination, exportedAt.UtcDateTime); - var digest = ComputeDigest(bytes); + var digest = ComputeDigest(bytes); files.Add(new JsonExportFile(entry.RelativePath, bytes.LongLength, digest)); totalBytes += bytes.LongLength; } @@ -232,10 +237,9 @@ public sealed class JsonExportSnapshotBuilder private sealed record PathResolution(Advisory Advisory, string RelativePath, IReadOnlyList Segments); - private static string ComputeDigest(ReadOnlySpan payload) - { - var hash = SHA256.HashData(payload); - var hex = Convert.ToHexString(hash).ToLowerInvariant(); - return $"sha256:{hex}"; - } -} + private string ComputeDigest(ReadOnlySpan payload) + { + var hex = _hash.ComputeHashHex(payload, HashAlgorithms.Sha256); + return $"sha256:{hex}"; + } +} diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonFeedExporter.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonFeedExporter.cs index f516e58ca..e61a131d2 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonFeedExporter.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonFeedExporter.cs @@ -4,12 +4,14 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Concelier.Core.Events; using StellaOps.Concelier.Models; using StellaOps.Concelier.Storage.Mongo.Advisories; using StellaOps.Concelier.Storage.Mongo.Exporting; +using StellaOps.Cryptography; using StellaOps.Plugin; namespace StellaOps.Concelier.Exporter.Json; @@ -51,15 +53,16 @@ public sealed class JsonFeedExporter : IFeedExporter public async Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken) { - var exportedAt = _timeProvider.GetUtcNow(); - var exportId = exportedAt.ToString(_options.DirectoryNameFormat, CultureInfo.InvariantCulture); - var exportRoot = Path.GetFullPath(_options.OutputRoot); + var exportedAt = _timeProvider.GetUtcNow(); + var exportId = exportedAt.ToString(_options.DirectoryNameFormat, CultureInfo.InvariantCulture); + var exportRoot = Path.GetFullPath(_options.OutputRoot); _logger.LogInformation("Starting JSON export {ExportId}", exportId); - var existingState = await _stateManager.GetAsync(ExporterId, cancellationToken).ConfigureAwait(false); - - var builder = new JsonExportSnapshotBuilder(_options, _pathResolver); + var existingState = await _stateManager.GetAsync(ExporterId, cancellationToken).ConfigureAwait(false); + + var cryptoHash = services.GetRequiredService(); + var builder = new JsonExportSnapshotBuilder(_options, _pathResolver, cryptoHash); var canonicalAdvisories = await MaterializeCanonicalAdvisoriesAsync(cancellationToken).ConfigureAwait(false); var result = await builder.WriteAsync(canonicalAdvisories, exportedAt, exportId, cancellationToken).ConfigureAwait(false); result = await JsonMirrorBundleWriter.WriteAsync(result, _options, services, _timeProvider, _logger, cancellationToken).ConfigureAwait(false); diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonMirrorBundleWriter.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonMirrorBundleWriter.cs index 4e976e267..be1d9b92f 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonMirrorBundleWriter.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/JsonMirrorBundleWriter.cs @@ -50,7 +50,8 @@ internal static class JsonMirrorBundleWriter ArgumentNullException.ThrowIfNull(timeProvider); ArgumentNullException.ThrowIfNull(logger); - var mirrorOptions = options.Mirror ?? new JsonExportOptions.JsonMirrorOptions(); + var cryptoHash = services.GetRequiredService(); + var mirrorOptions = options.Mirror ?? new JsonExportOptions.JsonMirrorOptions(); if (!mirrorOptions.Enabled || mirrorOptions.Domains.Count == 0) { return result; @@ -123,7 +124,7 @@ internal static class JsonMirrorBundleWriter await WriteFileAsync(bundlePath, bundleBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false); var bundleRelativePath = ToRelativePath(result.ExportDirectory, bundlePath); - var bundleDigest = ComputeDigest(bundleBytes); + var bundleDigest = ComputeDigest(cryptoHash, bundleBytes); var bundleLength = (long)bundleBytes.LongLength; additionalFiles.Add(new JsonExportFile(bundleRelativePath, bundleLength, bundleDigest)); @@ -142,7 +143,7 @@ internal static class JsonMirrorBundleWriter await WriteFileAsync(signaturePath, signatureBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false); var signatureRelativePath = ToRelativePath(result.ExportDirectory, signaturePath); - var signatureDigest = ComputeDigest(signatureBytes); + var signatureDigest = ComputeDigest(cryptoHash, signatureBytes); var signatureLength = (long)signatureBytes.LongLength; additionalFiles.Add(new JsonExportFile(signatureRelativePath, signatureLength, signatureDigest)); @@ -170,7 +171,7 @@ internal static class JsonMirrorBundleWriter await WriteFileAsync(manifestPath, manifestBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false); var manifestRelativePath = ToRelativePath(result.ExportDirectory, manifestPath); - var manifestDigest = ComputeDigest(manifestBytes); + var manifestDigest = ComputeDigest(cryptoHash, manifestBytes); var manifestLength = (long)manifestBytes.LongLength; additionalFiles.Add(new JsonExportFile(manifestRelativePath, manifestLength, manifestDigest)); @@ -198,7 +199,7 @@ internal static class JsonMirrorBundleWriter await WriteFileAsync(indexPath, indexBytes, exportedAtUtc, cancellationToken).ConfigureAwait(false); var indexRelativePath = ToRelativePath(result.ExportDirectory, indexPath); - var indexDigest = ComputeDigest(indexBytes); + var indexDigest = ComputeDigest(cryptoHash, indexBytes); var indexLength = (long)indexBytes.LongLength; additionalFiles.Add(new JsonExportFile(indexRelativePath, indexLength, indexDigest)); @@ -490,11 +491,11 @@ internal static class JsonMirrorBundleWriter return relative.Replace(Path.DirectorySeparatorChar, '/'); } - private static string ComputeDigest(ReadOnlySpan payload) - { - var hash = SHA256.HashData(payload); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } + private static string ComputeDigest(ICryptoHash hash, ReadOnlySpan payload) + { + var hex = hash.ComputeHashHex(payload, HashAlgorithms.Sha256); + return $"sha256:{hex}"; + } private static void TrySetDirectoryTimestamp(string directory, DateTime exportedAtUtc) { diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/VulnListJsonExportPathResolver.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/VulnListJsonExportPathResolver.cs index cc4aeb7e4..a18eb556d 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/VulnListJsonExportPathResolver.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Exporter.Json/VulnListJsonExportPathResolver.cs @@ -436,14 +436,14 @@ public sealed class VulnListJsonExportPathResolver : IJsonExportPathResolver var invalid = Path.GetInvalidFileNameChars(); Span buffer = stackalloc char[name.Length]; var count = 0; - foreach (var ch in name) - { - if (ch == '/' || ch == '\\' || Array.IndexOf(invalid, ch) >= 0) - { - buffer[count++] = '_'; - } - else - { + foreach (var ch in name) + { + if (ch == '/' || ch == '\\' || ch == ':' || Array.IndexOf(invalid, ch) >= 0) + { + buffer[count++] = '_'; + } + else + { buffer[count++] = ch; } } diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md index 0b714a6d2..60869867f 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md @@ -12,4 +12,4 @@ |MERGE-LNM-21-001 Migration plan authoring|BE-Merge, Architecture Guild|CONCELIER-LNM-21-101|**DONE (2025-11-03)** – Authored `docs/migration/no-merge.md` with rollout phases, backfill/validation checklists, rollback guidance, and ownership matrix for the Link-Not-Merge cutover.| |MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DONE (2025-11-07)** – Feature flag now defaults to Link-Not-Merge mode (`NoMergeEnabled=true`) across options/config, analyzers enforce deprecation, and WebService option tests cover the regression; dotnet CLI validation still queued for a workstation with preview SDK.
2025-11-05 14:42Z: Implemented `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.
2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.
2025-11-07 03:25Z: Default-on toggle + job gating surfacing ingestion test brittleness; guard logs capture requests missing `upstream.contentHash`.
2025-11-07 19:45Z: Set `ConcelierOptions.Features.NoMergeEnabled` default to `true`, added regression coverage (`Features_NoMergeEnabled_DefaultsToTrue`), and rechecked ingest helpers to carry canonical links before closing the task.| > 2025-11-03: Catalogued call sites (WebService Program `AddMergeModule`, built-in job registration `merge:reconcile`, `MergeReconcileJob`) and confirmed unit tests are the only direct `MergeAsync` callers; next step is to define analyzer + replacement observability coverage. -|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|**DOING (2025-11-07)** – Replacing legacy merge determinism harness with observation/linkset regression plan; tracking scenarios in `docs/dev/lnm-determinism-tests.md` before porting fixtures.
2025-11-07 20:05Z: Ported merge determinism fixture into `AdvisoryObservationFactoryTests.Create_IsDeterministicAcrossRuns` and removed the redundant merge integration test.| +|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|**DONE (2025-11-07)** – Legacy merge determinism suite replaced by observation/linkset/export regressions. Added coverage across `AdvisoryObservationFactoryTests` (raw references + conflict notes), `AdvisoryEventLogTests` (sorted statement IDs), and `JsonExportSnapshotBuilderTests` (order-independent digests). `docs/dev/lnm-determinism-tests.md` updated to reflect parity.| diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/RawDocumentFactory.cs b/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/RawDocumentFactory.cs index 3b42d2aa2..dc2161ef4 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/RawDocumentFactory.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/RawDocumentFactory.cs @@ -21,14 +21,14 @@ public static class RawDocumentFactory return new AdvisoryRawDocument(tenant, source, upstream, clonedContent, identifiers, linkset, advisoryKey, normalizedLinks, supersedes); } - public static VexRawDocument CreateVex( - string tenant, - RawSourceMetadata source, - RawUpstreamMetadata upstream, - RawContent content, - RawLinkset linkset, - ImmutableArray statements, - string? supersedes = null) + public static VexRawDocument CreateVex( + string tenant, + RawSourceMetadata source, + RawUpstreamMetadata upstream, + RawContent content, + RawLinkset linkset, + ImmutableArray? statements = null, + string? supersedes = null) { var clonedContent = content with { Raw = Clone(content.Raw) }; return new VexRawDocument(tenant, source, upstream, clonedContent, linkset, statements, supersedes); diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/VexRawDocument.cs b/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/VexRawDocument.cs index 1e3820e06..381a61623 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/VexRawDocument.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.RawModels/VexRawDocument.cs @@ -3,15 +3,17 @@ using System.Text.Json.Serialization; namespace StellaOps.Concelier.RawModels; -public sealed record VexRawDocument( - [property: JsonPropertyName("tenant")] string Tenant, - [property: JsonPropertyName("source")] RawSourceMetadata Source, - [property: JsonPropertyName("upstream")] RawUpstreamMetadata Upstream, - [property: JsonPropertyName("content")] RawContent Content, - [property: JsonPropertyName("linkset")] RawLinkset Linkset, - [property: JsonPropertyName("statements")] ImmutableArray Statements, - [property: JsonPropertyName("supersedes")] string? Supersedes = null) -{ +public sealed record VexRawDocument( + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("source")] RawSourceMetadata Source, + [property: JsonPropertyName("upstream")] RawUpstreamMetadata Upstream, + [property: JsonPropertyName("content")] RawContent Content, + [property: JsonPropertyName("linkset")] RawLinkset Linkset, + [property: JsonPropertyName("statements")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + ImmutableArray? Statements = null, + [property: JsonPropertyName("supersedes")] string? Supersedes = null) +{ public VexRawDocument WithSupersedes(string supersedes) => this with { Supersedes = supersedes }; } diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryCanonicalKeyBackfillMigration.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryCanonicalKeyBackfillMigration.cs index 1ac851d21..62d7f9eae 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryCanonicalKeyBackfillMigration.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryCanonicalKeyBackfillMigration.cs @@ -126,7 +126,7 @@ public sealed class EnsureAdvisoryCanonicalKeyBackfillMigration : IMongoMigratio return string.Empty; } - return value.IsString ? value.AsString : value.ToString(); + return value.IsString ? value.AsString : value.ToString() ?? string.Empty; } private static string? GetOptionalString(BsonDocument document, string name) @@ -150,7 +150,7 @@ public sealed class EnsureAdvisoryCanonicalKeyBackfillMigration : IMongoMigratio BsonInt32 i => i.AsInt32.ToString(CultureInfo.InvariantCulture), BsonInt64 l => l.AsInt64.ToString(CultureInfo.InvariantCulture), BsonDouble d => d.AsDouble.ToString(CultureInfo.InvariantCulture), - _ => value.ToString() + _ => value?.ToString() ?? string.Empty }; } diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryObservationsRawLinksetMigration.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryObservationsRawLinksetMigration.cs index 226bf1480..4e0dc4a17 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryObservationsRawLinksetMigration.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Migrations/EnsureAdvisoryObservationsRawLinksetMigration.cs @@ -157,7 +157,7 @@ public sealed class EnsureAdvisoryObservationsRawLinksetMigration : IMongoMigrat content, identifiers, linkset, - supersedes.IsBsonNull ? null : supersedes.AsString); + Supersedes: supersedes.IsBsonNull ? null : supersedes.AsString); } private static RawSourceMetadata MapSource(BsonDocument source) diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/MongoBootstrapper.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/MongoBootstrapper.cs index c21ed3f2a..f66d8871f 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/MongoBootstrapper.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/MongoBootstrapper.cs @@ -90,12 +90,27 @@ public sealed class MongoBootstrapper _logger.LogInformation("Mongo bootstrapper completed"); } - private async Task> ListCollectionsAsync(CancellationToken cancellationToken) - { - using var cursor = await _database.ListCollectionNamesAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - var list = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); - return new HashSet(list, StringComparer.Ordinal); - } + private async Task> ListCollectionsAsync(CancellationToken cancellationToken) + { + using var cursor = await _database.ListCollectionNamesAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + var list = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); + return new HashSet(list, StringComparer.Ordinal); + } + + private async Task CollectionIsViewAsync(string collectionName, CancellationToken cancellationToken) + { + var filter = Builders.Filter.Eq("name", collectionName); + var options = new ListCollectionsOptions { Filter = filter }; + using var cursor = await _database.ListCollectionsAsync(options, cancellationToken).ConfigureAwait(false); + var collections = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); + if (collections.Count == 0) + { + return false; + } + + var typeValue = collections[0].GetValue("type", BsonString.Empty).AsString; + return string.Equals(typeValue, "view", StringComparison.OrdinalIgnoreCase); + } private Task EnsureLocksIndexesAsync(CancellationToken cancellationToken) { @@ -129,9 +144,15 @@ public sealed class MongoBootstrapper return collection.Indexes.CreateManyAsync(indexes, cancellationToken); } - private Task EnsureAdvisoryIndexesAsync(CancellationToken cancellationToken) - { - var collection = _database.GetCollection(MongoStorageDefaults.Collections.Advisory); + private async Task EnsureAdvisoryIndexesAsync(CancellationToken cancellationToken) + { + if (await CollectionIsViewAsync(MongoStorageDefaults.Collections.Advisory, cancellationToken).ConfigureAwait(false)) + { + _logger.LogDebug("Skipping advisory index creation because {Collection} is a view", MongoStorageDefaults.Collections.Advisory); + return; + } + + var collection = _database.GetCollection(MongoStorageDefaults.Collections.Advisory); var indexes = new List> { new( @@ -159,7 +180,7 @@ public sealed class MongoBootstrapper new CreateIndexOptions { Name = "advisory_normalizedVersions_value", Sparse = true })); } - return collection.Indexes.CreateManyAsync(indexes, cancellationToken); + await collection.Indexes.CreateManyAsync(indexes, cancellationToken).ConfigureAwait(false); } private Task EnsureDocumentsIndexesAsync(CancellationToken cancellationToken) diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Raw/MongoAdvisoryRawRepository.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Raw/MongoAdvisoryRawRepository.cs index 670efe0df..cf9c91e57 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Raw/MongoAdvisoryRawRepository.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Raw/MongoAdvisoryRawRepository.cs @@ -1,16 +1,18 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Globalization; -using System.Text; -using System.Linq; -using System.Text.Json; -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Bson.IO; -using Microsoft.Extensions.Logging; -using StellaOps.Concelier.Core.Raw; -using StellaOps.Concelier.RawModels; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Driver; +using StellaOps.Concelier.Core.Raw; +using StellaOps.Concelier.RawModels; +using StellaOps.Ingestion.Telemetry; namespace StellaOps.Concelier.Storage.Mongo.Raw; @@ -34,76 +36,115 @@ internal sealed class MongoAdvisoryRawRepository : IAdvisoryRawRepository _collection = database.GetCollection(MongoStorageDefaults.Collections.AdvisoryRaw); } - public async Task UpsertAsync(AdvisoryRawDocument document, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(document); - - var tenant = document.Tenant; - var vendor = document.Source.Vendor; - var upstreamId = document.Upstream.UpstreamId; - var contentHash = document.Upstream.ContentHash; - - var baseFilter = Builders.Filter.Eq("tenant", tenant) & - Builders.Filter.Eq("source.vendor", vendor) & - Builders.Filter.Eq("upstream.upstream_id", upstreamId); + public async Task UpsertAsync(AdvisoryRawDocument document, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(document); + + var tenant = document.Tenant; + var vendor = document.Source.Vendor; + var upstreamId = document.Upstream.UpstreamId; + var contentHash = document.Upstream.ContentHash; + var sourceUri = ResolveProvenanceUri(document); + + var baseFilter = Builders.Filter.Eq("tenant", tenant) & + Builders.Filter.Eq("source.vendor", vendor) & + Builders.Filter.Eq("upstream.upstream_id", upstreamId); var duplicateFilter = baseFilter & Builders.Filter.Eq("upstream.content_hash", contentHash); - var duplicate = await _collection - .Find(duplicateFilter) - .Limit(1) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - - if (duplicate is not null) - { - var existing = MapToRecord(duplicate); - return new AdvisoryRawUpsertResult(false, existing); - } - - var previous = await _collection - .Find(baseFilter) - .Sort(Builders.Sort.Descending("ingested_at").Descending("_id")) - .Limit(1) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - - var supersedesId = previous?["_id"]?.AsString; - var recordDocument = CreateBsonDocument(document, supersedesId); - - try - { - await _collection.InsertOneAsync(recordDocument, cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) - { - _logger.LogWarning( - ex, - "Duplicate key detected while inserting advisory_raw document tenant={Tenant} vendor={Vendor} upstream={Upstream} hash={Hash}", - tenant, - vendor, - upstreamId, - contentHash); - - var existingDoc = await _collection - .Find(duplicateFilter) - .Limit(1) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - - if (existingDoc is not null) - { - var existing = MapToRecord(existingDoc); - return new AdvisoryRawUpsertResult(false, existing); - } - - throw; - } - - var inserted = MapToRecord(recordDocument); - return new AdvisoryRawUpsertResult(true, inserted); - } + using var fetchActivity = IngestionTelemetry.StartFetchActivity(tenant, vendor, upstreamId, contentHash, sourceUri); + var fetchWatch = Stopwatch.StartNew(); + + var duplicate = await _collection + .Find(duplicateFilter) + .Limit(1) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (duplicate is not null) + { + fetchWatch.Stop(); + fetchActivity?.SetTag("result", "duplicate"); + fetchActivity?.SetStatus(ActivityStatusCode.Ok); + IngestionTelemetry.RecordLatency(tenant, vendor, IngestionTelemetry.PhaseFetch, fetchWatch.Elapsed); + + var existing = MapToRecord(duplicate); + return new AdvisoryRawUpsertResult(false, existing); + } + + var previous = await _collection + .Find(baseFilter) + .Sort(Builders.Sort.Descending("ingested_at").Descending("_id")) + .Limit(1) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + fetchWatch.Stop(); + fetchActivity?.SetTag("result", previous is null ? "new" : "supersede"); + fetchActivity?.SetStatus(ActivityStatusCode.Ok); + IngestionTelemetry.RecordLatency(tenant, vendor, IngestionTelemetry.PhaseFetch, fetchWatch.Elapsed); + + var supersedesId = previous?["_id"]?.AsString; + var recordDocument = CreateBsonDocument(document, supersedesId); + + var writeWatch = Stopwatch.StartNew(); + using var writeActivity = IngestionTelemetry.StartWriteActivity(tenant, vendor, upstreamId, contentHash, MongoStorageDefaults.Collections.AdvisoryRaw); + + try + { + await _collection.InsertOneAsync(recordDocument, cancellationToken: cancellationToken).ConfigureAwait(false); + writeActivity?.SetTag("result", IngestionTelemetry.ResultOk); + writeActivity?.SetStatus(ActivityStatusCode.Ok); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + writeActivity?.SetTag("result", IngestionTelemetry.ResultNoop); + writeActivity?.SetStatus(ActivityStatusCode.Error, "duplicate_key"); + + _logger.LogWarning( + ex, + "Duplicate key detected while inserting advisory_raw document tenant={Tenant} vendor={Vendor} upstream={Upstream} hash={Hash}", + tenant, + vendor, + upstreamId, + contentHash); + + var existingDoc = await _collection + .Find(duplicateFilter) + .Limit(1) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (existingDoc is not null) + { + var existing = MapToRecord(existingDoc); + return new AdvisoryRawUpsertResult(false, existing); + } + + throw; + } + finally + { + writeWatch.Stop(); + IngestionTelemetry.RecordLatency(tenant, vendor, IngestionTelemetry.PhaseWrite, writeWatch.Elapsed); + } + + var inserted = MapToRecord(recordDocument); + return new AdvisoryRawUpsertResult(true, inserted); + } + + private static string? ResolveProvenanceUri(AdvisoryRawDocument document) + { + if (document.Upstream?.Provenance is null) + { + return null; + } + + return document.Upstream.Provenance.TryGetValue("uri", out var uri) && !string.IsNullOrWhiteSpace(uri) + ? uri + : null; + } public async Task FindByIdAsync(string tenant, string id, CancellationToken cancellationToken) { diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj index eca05ada1..e5210d249 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj @@ -11,8 +11,9 @@ - - - - - + + + + + + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.actual.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.actual.json new file mode 100644 index 000000000..c7b002ab3 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.actual.json @@ -0,0 +1,207 @@ +[ + { + "advisoryKey": "acsc/multi/https-origin-example-advisories-info-bulletin", + "affectedPackages": [], + "aliases": [ + "ACSC-2025-011", + "Bulletin", + "https://origin.example/advisories/info-bulletin" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "acsc", + "kind": "document", + "value": "https://origin.example/feeds/multi/rss", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + }, + { + "source": "acsc", + "kind": "feed", + "value": "multi", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "summary" + ] + }, + { + "source": "acsc", + "kind": "mapping", + "value": "https://origin.example/advisories/info-bulletin", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + } + ], + "published": "2025-10-12T02:30:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://origin.example/advisories/info-bulletin", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "multi", + "summary": "Information bulletin", + "url": "https://origin.example/advisories/info-bulletin" + } + ], + "severity": null, + "summary": "Serial number: ACSC-2025-011\n\nAdvisory type: Bulletin\n\nGeneral guidance bulletin.", + "title": "Information bulletin" + }, + { + "advisoryKey": "acsc/multi/https-origin-example-advisories-router-critical", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "ExampleCo Router X", + "platform": null, + "versionRanges": [], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "acsc", + "kind": "affected", + "value": "ExampleCo Router X", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages" + ] + } + ] + }, + { + "type": "vendor", + "identifier": "ExampleCo Router Y", + "platform": null, + "versionRanges": [], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "acsc", + "kind": "affected", + "value": "ExampleCo Router Y", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages" + ] + } + ] + } + ], + "aliases": [ + "ACSC-2025-010", + "CVE-2025-0001", + "https://origin.example/advisories/router-critical" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "acsc", + "kind": "document", + "value": "https://origin.example/feeds/multi/rss", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + }, + { + "source": "acsc", + "kind": "feed", + "value": "multi", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "summary" + ] + }, + { + "source": "acsc", + "kind": "mapping", + "value": "https://origin.example/advisories/router-critical", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + } + ], + "published": "2025-10-12T04:45:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://origin.example/advisories/router-critical", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "multi", + "summary": "Critical router vulnerability", + "url": "https://origin.example/advisories/router-critical" + }, + { + "kind": "reference", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://vendor.example/router/patch", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": "vendor patch", + "url": "https://vendor.example/router/patch" + } + ], + "severity": "critical", + "summary": "Serial number: ACSC-2025-010\n\nSeverity: Critical\n\nSystems affected: ExampleCo Router X, ExampleCo Router Y\n\nRemote code execution on ExampleCo routers. See vendor patch.\n\nCVE references: CVE-2025-0001", + "title": "Critical router vulnerability" + } +] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.json index 68cb04dd0..c7b002ab3 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories-multi.snapshot.json @@ -1,201 +1,207 @@ -[ - { - "advisoryKey": "acsc/multi/https-origin-example-advisories-info-bulletin", - "affectedPackages": [], - "aliases": [ - "ACSC-2025-011", - "Bulletin", - "https://origin.example/advisories/info-bulletin" - ], - "credits": [], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": null, - "provenance": [ - { - "source": "acsc", - "kind": "document", - "value": "https://origin.example/feeds/multi/rss", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages", - "aliases", - "references", - "summary" - ] - }, - { - "source": "acsc", - "kind": "feed", - "value": "multi", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "summary" - ] - }, - { - "source": "acsc", - "kind": "mapping", - "value": "https://origin.example/advisories/info-bulletin", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages", - "aliases", - "references", - "summary" - ] - } - ], - "published": "2025-10-12T02:30:00+00:00", - "references": [ - { - "kind": "advisory", - "provenance": { - "source": "acsc", - "kind": "reference", - "value": "https://origin.example/advisories/info-bulletin", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": "multi", - "summary": "Information bulletin", - "url": "https://origin.example/advisories/info-bulletin" - } - ], - "severity": null, - "summary": "Serial number: ACSC-2025-011\n\nAdvisory type: Bulletin\n\nGeneral guidance bulletin.", - "title": "Information bulletin" - }, - { - "advisoryKey": "acsc/multi/https-origin-example-advisories-router-critical", - "affectedPackages": [ - { - "type": "vendor", - "identifier": "ExampleCo Router X", - "platform": null, - "versionRanges": [], - "normalizedVersions": [], - "statuses": [], - "provenance": [ - { - "source": "acsc", - "kind": "affected", - "value": "ExampleCo Router X", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages" - ] - } - ] - }, - { - "type": "vendor", - "identifier": "ExampleCo Router Y", - "platform": null, - "versionRanges": [], - "normalizedVersions": [], - "statuses": [], - "provenance": [ - { - "source": "acsc", - "kind": "affected", - "value": "ExampleCo Router Y", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages" - ] - } - ] - } - ], - "aliases": [ - "ACSC-2025-010", - "CVE-2025-0001", - "https://origin.example/advisories/router-critical" - ], - "credits": [], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": null, - "provenance": [ - { - "source": "acsc", - "kind": "document", - "value": "https://origin.example/feeds/multi/rss", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages", - "aliases", - "references", - "summary" - ] - }, - { - "source": "acsc", - "kind": "feed", - "value": "multi", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "summary" - ] - }, - { - "source": "acsc", - "kind": "mapping", - "value": "https://origin.example/advisories/router-critical", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages", - "aliases", - "references", - "summary" - ] - } - ], - "published": "2025-10-12T04:45:00+00:00", - "references": [ - { - "kind": "advisory", - "provenance": { - "source": "acsc", - "kind": "reference", - "value": "https://origin.example/advisories/router-critical", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": "multi", - "summary": "Critical router vulnerability", - "url": "https://origin.example/advisories/router-critical" - }, - { - "kind": "reference", - "provenance": { - "source": "acsc", - "kind": "reference", - "value": "https://vendor.example/router/patch", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": null, - "summary": "vendor patch", - "url": "https://vendor.example/router/patch" - } - ], - "severity": "critical", - "summary": "Serial number: ACSC-2025-010\n\nSeverity: Critical\n\nSystems affected: ExampleCo Router X, ExampleCo Router Y\n\nRemote code execution on ExampleCo routers. See vendor patch.\n\nCVE references: CVE-2025-0001", - "title": "Critical router vulnerability" - } +[ + { + "advisoryKey": "acsc/multi/https-origin-example-advisories-info-bulletin", + "affectedPackages": [], + "aliases": [ + "ACSC-2025-011", + "Bulletin", + "https://origin.example/advisories/info-bulletin" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "acsc", + "kind": "document", + "value": "https://origin.example/feeds/multi/rss", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + }, + { + "source": "acsc", + "kind": "feed", + "value": "multi", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "summary" + ] + }, + { + "source": "acsc", + "kind": "mapping", + "value": "https://origin.example/advisories/info-bulletin", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + } + ], + "published": "2025-10-12T02:30:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://origin.example/advisories/info-bulletin", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "multi", + "summary": "Information bulletin", + "url": "https://origin.example/advisories/info-bulletin" + } + ], + "severity": null, + "summary": "Serial number: ACSC-2025-011\n\nAdvisory type: Bulletin\n\nGeneral guidance bulletin.", + "title": "Information bulletin" + }, + { + "advisoryKey": "acsc/multi/https-origin-example-advisories-router-critical", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "ExampleCo Router X", + "platform": null, + "versionRanges": [], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "acsc", + "kind": "affected", + "value": "ExampleCo Router X", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages" + ] + } + ] + }, + { + "type": "vendor", + "identifier": "ExampleCo Router Y", + "platform": null, + "versionRanges": [], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "acsc", + "kind": "affected", + "value": "ExampleCo Router Y", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages" + ] + } + ] + } + ], + "aliases": [ + "ACSC-2025-010", + "CVE-2025-0001", + "https://origin.example/advisories/router-critical" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "acsc", + "kind": "document", + "value": "https://origin.example/feeds/multi/rss", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + }, + { + "source": "acsc", + "kind": "feed", + "value": "multi", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "summary" + ] + }, + { + "source": "acsc", + "kind": "mapping", + "value": "https://origin.example/advisories/router-critical", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + } + ], + "published": "2025-10-12T04:45:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://origin.example/advisories/router-critical", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "multi", + "summary": "Critical router vulnerability", + "url": "https://origin.example/advisories/router-critical" + }, + { + "kind": "reference", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://vendor.example/router/patch", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": "vendor patch", + "url": "https://vendor.example/router/patch" + } + ], + "severity": "critical", + "summary": "Serial number: ACSC-2025-010\n\nSeverity: Critical\n\nSystems affected: ExampleCo Router X, ExampleCo Router Y\n\nRemote code execution on ExampleCo routers. See vendor patch.\n\nCVE references: CVE-2025-0001", + "title": "Critical router vulnerability" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.actual.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.actual.json new file mode 100644 index 000000000..4ac0ece1d --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.actual.json @@ -0,0 +1,91 @@ +[ + { + "advisoryKey": "acsc/alerts/https-origin-example-advisories-example", + "affectedPackages": [], + "aliases": [ + "ACSC-2025-001", + "Alert", + "https://origin.example/advisories/example" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "acsc", + "kind": "document", + "value": "https://origin.example/feeds/alerts/rss", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + }, + { + "source": "acsc", + "kind": "feed", + "value": "alerts", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "summary" + ] + }, + { + "source": "acsc", + "kind": "mapping", + "value": "https://origin.example/advisories/example", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + } + ], + "published": "2025-10-12T03:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://origin.example/advisories/example", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "alerts", + "summary": "ACSC-2025-001 Example Advisory", + "url": "https://origin.example/advisories/example" + }, + { + "kind": "reference", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://vendor.example/patch", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": "Vendor patch", + "url": "https://vendor.example/patch" + } + ], + "severity": null, + "summary": "Serial number: ACSC-2025-001\n\nAdvisory type: Alert\n\nFirst paragraph describing issue.\n\nSecond paragraph with Vendor patch.", + "title": "ACSC-2025-001 Example Advisory" + } +] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.json index b33b9dd05..4ac0ece1d 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Acsc.Tests/Acsc/Fixtures/acsc-advisories.snapshot.json @@ -1,88 +1,91 @@ -[ - { - "advisoryKey": "acsc/alerts/https-origin-example-advisories-example", - "affectedPackages": [], - "aliases": [ - "ACSC-2025-001", - "Alert", - "https://origin.example/advisories/example" - ], - "credits": [], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": null, - "provenance": [ - { - "source": "acsc", - "kind": "document", - "value": "https://origin.example/feeds/alerts/rss", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages", - "aliases", - "references", - "summary" - ] - }, - { - "source": "acsc", - "kind": "feed", - "value": "alerts", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "summary" - ] - }, - { - "source": "acsc", - "kind": "mapping", - "value": "https://origin.example/advisories/example", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [ - "affectedpackages", - "aliases", - "references", - "summary" - ] - } - ], - "published": "2025-10-12T03:00:00+00:00", - "references": [ - { - "kind": "advisory", - "provenance": { - "source": "acsc", - "kind": "reference", - "value": "https://origin.example/advisories/example", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": "alerts", - "summary": "ACSC-2025-001 Example Advisory", - "url": "https://origin.example/advisories/example" - }, - { - "kind": "reference", - "provenance": { - "source": "acsc", - "kind": "reference", - "value": "https://vendor.example/patch", - "decisionReason": null, - "recordedAt": "2025-10-12T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": null, - "summary": "Vendor patch", - "url": "https://vendor.example/patch" - } - ], - "severity": null, - "summary": "Serial number: ACSC-2025-001\n\nAdvisory type: Alert\n\nFirst paragraph describing issue.\n\nSecond paragraph with Vendor patch.", - "title": "ACSC-2025-001 Example Advisory" - } +[ + { + "advisoryKey": "acsc/alerts/https-origin-example-advisories-example", + "affectedPackages": [], + "aliases": [ + "ACSC-2025-001", + "Alert", + "https://origin.example/advisories/example" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "acsc", + "kind": "document", + "value": "https://origin.example/feeds/alerts/rss", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + }, + { + "source": "acsc", + "kind": "feed", + "value": "alerts", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "summary" + ] + }, + { + "source": "acsc", + "kind": "mapping", + "value": "https://origin.example/advisories/example", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [ + "affectedpackages", + "aliases", + "references", + "summary" + ] + } + ], + "published": "2025-10-12T03:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://origin.example/advisories/example", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "alerts", + "summary": "ACSC-2025-001 Example Advisory", + "url": "https://origin.example/advisories/example" + }, + { + "kind": "reference", + "provenance": { + "source": "acsc", + "kind": "reference", + "value": "https://vendor.example/patch", + "decisionReason": null, + "recordedAt": "2025-10-12T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": "Vendor patch", + "url": "https://vendor.example/patch" + } + ], + "severity": null, + "summary": "Serial number: ACSC-2025-001\n\nAdvisory type: Alert\n\nFirst paragraph describing issue.\n\nSecond paragraph with Vendor patch.", + "title": "ACSC-2025-001 Example Advisory" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/CccsConnectorTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/CccsConnectorTests.cs index 7d06d55f3..5b780abd3 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/CccsConnectorTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cccs.Tests/CccsConnectorTests.cs @@ -10,7 +10,8 @@ using Microsoft.Extensions.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using MongoDB.Bson; +using MongoDB.Bson; +using MongoDB.Driver; using StellaOps.Concelier.Connector.Cccs; using StellaOps.Concelier.Connector.Cccs.Configuration; using StellaOps.Concelier.Connector.Common; @@ -79,11 +80,19 @@ public sealed class CccsConnectorTests : IAsyncLifetime await using var provider = await BuildServiceProviderAsync(); SeedFeedResponses(); - var connector = provider.GetRequiredService(); - await connector.FetchAsync(provider, CancellationToken.None); - - var documentStore = provider.GetRequiredService(); - var document = await documentStore.FindBySourceAndUriAsync(CccsConnectorPlugin.SourceName, "https://www.cyber.gc.ca/en/alerts-advisories/test-advisory", CancellationToken.None); + var connector = provider.GetRequiredService(); + await connector.FetchAsync(provider, CancellationToken.None); + + var mongo = provider.GetRequiredService(); + var docCollection = mongo.GetCollection("document"); + var documentsSnapshot = await docCollection.Find(FilterDefinition.Empty).ToListAsync(); + + System.IO.Directory.CreateDirectory(System.IO.Path.Combine(AppContext.BaseDirectory, "tmp")); + var debugPath = System.IO.Path.Combine(AppContext.BaseDirectory, "tmp", "cccs-documents.json"); + await System.IO.File.WriteAllTextAsync(debugPath, documentsSnapshot.ToJson(new MongoDB.Bson.IO.JsonWriterSettings { Indent = true })); + + var documentStore = provider.GetRequiredService(); + var document = await documentStore.FindBySourceAndUriAsync(CccsConnectorPlugin.SourceName, "https://www.cyber.gc.ca/en/alerts-advisories/test-advisory", CancellationToken.None); document.Should().NotBeNull(); document!.Status.Should().Be(DocumentStatuses.PendingParse); document.Metadata.Should().ContainKey("cccs.language").WhoseValue.Should().Be("en"); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertFr.Tests/CertFr/Fixtures/certfr-advisories.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertFr.Tests/CertFr/Fixtures/certfr-advisories.snapshot.json index e5e045427..b17b06cd9 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertFr.Tests/CertFr/Fixtures/certfr-advisories.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertFr.Tests/CertFr/Fixtures/certfr-advisories.snapshot.json @@ -1,205 +1,226 @@ -[ - { - "advisoryKey": "cert-fr/AV-2024.001", - "affectedPackages": [ - { - "identifier": "AV-2024.001", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "certfr.summary": "Résumé de la première alerte.", - "certfr.content": "AV-2024.001 Alerte CERT-FR AV-2024.001 L'exploitation active de la vulnérabilité est surveillée. Consultez les indications du fournisseur .", - "certfr.reference.count": "1" - } - }, - "provenance": { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CERT-FR:AV-2024.001" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "fr", - "modified": null, - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/" - } - ], - "published": "2024-10-03T00:00:00+00:00", - "references": [ - { - "kind": "reference", - "provenance": { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/" - }, - "sourceTag": null, - "summary": null, - "url": "https://vendor.example.com/patch" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/" - }, - "sourceTag": "cert-fr", - "summary": "Résumé de la première alerte.", - "url": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/" - } - ], - "severity": null, - "summary": "Résumé de la première alerte.", - "title": "AV-2024.001 - Première alerte" - }, - { - "advisoryKey": "cert-fr/AV-2024.002", - "affectedPackages": [ - { - "identifier": "AV-2024.002", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "certfr.summary": "Résumé de la deuxième alerte.", - "certfr.content": "AV-2024.002 Alerte CERT-FR AV-2024.002 Des correctifs sont disponibles pour plusieurs produits. Note de mise à jour Correctif", - "certfr.reference.count": "2" - } - }, - "provenance": { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CERT-FR:AV-2024.002" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "fr", - "modified": null, - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" - } - ], - "published": "2024-10-03T00:00:00+00:00", - "references": [ - { - "kind": "reference", - "provenance": { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" - }, - "sourceTag": null, - "summary": null, - "url": "https://support.example.com/kb/KB-1234" - }, - { - "kind": "reference", - "provenance": { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" - }, - "sourceTag": null, - "summary": null, - "url": "https://support.example.com/kb/KB-5678" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-03T00:01:00+00:00", - "source": "cert-fr", - "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" - }, - "sourceTag": "cert-fr", - "summary": "Résumé de la deuxième alerte.", - "url": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" - } - ], - "severity": null, - "summary": "Résumé de la deuxième alerte.", - "title": "AV-2024.002 - Deuxième alerte" - } +[ + { + "advisoryKey": "cert-fr/AV-2024.001", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "AV-2024.001", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "certfr.summary": "Résumé de la première alerte.", + "certfr.content": "AV-2024.001 Alerte CERT-FR AV-2024.001 L'exploitation active de la vulnérabilité est surveillée. Consultez les indications du fournisseur .", + "certfr.reference.count": "1" + } + }, + "provenance": { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CERT-FR:AV-2024.001" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "fr", + "modified": null, + "provenance": [ + { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-10-03T00:00:00+00:00", + "references": [ + { + "kind": "reference", + "provenance": { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://vendor.example.com/patch" + }, + { + "kind": "advisory", + "provenance": { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "cert-fr", + "summary": "Résumé de la première alerte.", + "url": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/" + } + ], + "severity": null, + "summary": "Résumé de la première alerte.", + "title": "AV-2024.001 - Première alerte" + }, + { + "advisoryKey": "cert-fr/AV-2024.002", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "AV-2024.002", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "certfr.summary": "Résumé de la deuxième alerte.", + "certfr.content": "AV-2024.002 Alerte CERT-FR AV-2024.002 Des correctifs sont disponibles pour plusieurs produits. Note de mise à jour Correctif", + "certfr.reference.count": "2" + } + }, + "provenance": { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CERT-FR:AV-2024.002" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "fr", + "modified": null, + "provenance": [ + { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-10-03T00:00:00+00:00", + "references": [ + { + "kind": "reference", + "provenance": { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://support.example.com/kb/KB-1234" + }, + { + "kind": "reference", + "provenance": { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://support.example.com/kb/KB-5678" + }, + { + "kind": "advisory", + "provenance": { + "source": "cert-fr", + "kind": "document", + "value": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/", + "decisionReason": null, + "recordedAt": "2024-10-03T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "cert-fr", + "summary": "Résumé de la deuxième alerte.", + "url": "https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/" + } + ], + "severity": null, + "summary": "Résumé de la deuxième alerte.", + "title": "AV-2024.002 - Deuxième alerte" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.json index 142193185..ca4beb1bf 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.json @@ -1,128 +1,141 @@ -{ - "advisoryKey": "CIAD-2024-0005", - "affectedPackages": [ - { - "identifier": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-20T00:01:00+00:00", - "source": "cert-in", - "value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "certin.vendor": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the " - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-20T00:01:00+00:00", - "source": "cert-in", - "value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CIAD-2024-0005", - "CVE-2024-9990", - "CVE-2024-9991" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": "2024-04-15T10:00:00+00:00", - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-04-20T00:00:00+00:00", - "source": "cert-in", - "value": "https://cert-in.example/advisory/CIAD-2024-0005" - }, - { - "fieldMask": [], - "kind": "mapping", - "recordedAt": "2024-04-20T00:01:00+00:00", - "source": "cert-in", - "value": "CIAD-2024-0005" - } - ], - "published": "2024-04-15T10:00:00+00:00", - "references": [ - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-20T00:01:00+00:00", - "source": "cert-in", - "value": "https://cert-in.example/advisory/CIAD-2024-0005" - }, - "sourceTag": "cert-in", - "summary": null, - "url": "https://cert-in.example/advisory/CIAD-2024-0005" - }, - { - "kind": "reference", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-20T00:01:00+00:00", - "source": "cert-in", - "value": "https://vendor.example.com/advisories/example-gateway-bulletin" - }, - "sourceTag": null, - "summary": null, - "url": "https://vendor.example.com/advisories/example-gateway-bulletin" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-20T00:01:00+00:00", - "source": "cert-in", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-9990" - }, - "sourceTag": "CVE-2024-9990", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-9990" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-20T00:01:00+00:00", - "source": "cert-in", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-9991" - }, - "sourceTag": "CVE-2024-9991", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-9991" - } - ], - "severity": "high", - "summary": "Example Gateway devices vulnerable to remote code execution (CVE-2024-9990).", - "title": "Multiple vulnerabilities in Example Gateway" +{ + "advisoryKey": "CIAD-2024-0005", + "affectedPackages": [ + { + "type": "ics-vendor", + "identifier": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "certin.vendor": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the " + } + }, + "provenance": { + "source": "cert-in", + "kind": "affected", + "value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "cert-in", + "kind": "affected", + "value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CIAD-2024-0005", + "CVE-2024-9990", + "CVE-2024-9991" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-04-15T10:00:00+00:00", + "provenance": [ + { + "source": "cert-in", + "kind": "document", + "value": "https://cert-in.example/advisory/CIAD-2024-0005", + "decisionReason": null, + "recordedAt": "2024-04-20T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "cert-in", + "kind": "mapping", + "value": "CIAD-2024-0005", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-04-15T10:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://cert-in.example/advisory/CIAD-2024-0005", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "cert-in", + "summary": null, + "url": "https://cert-in.example/advisory/CIAD-2024-0005" + }, + { + "kind": "reference", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://vendor.example.com/advisories/example-gateway-bulletin", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://vendor.example.com/advisories/example-gateway-bulletin" + }, + { + "kind": "advisory", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9990", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9990", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9990" + }, + { + "kind": "advisory", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9991", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9991", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9991" + } + ], + "severity": "high", + "summary": "Example Gateway devices vulnerable to remote code execution (CVE-2024-9990).", + "title": "Multiple vulnerabilities in Example Gateway" } \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.snapshot.json new file mode 100644 index 000000000..ca4beb1bf --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.CertIn.Tests/CertIn/Fixtures/expected-advisory.snapshot.json @@ -0,0 +1,141 @@ +{ + "advisoryKey": "CIAD-2024-0005", + "affectedPackages": [ + { + "type": "ics-vendor", + "identifier": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "certin.vendor": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the " + } + }, + "provenance": { + "source": "cert-in", + "kind": "affected", + "value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "cert-in", + "kind": "affected", + "value": "Example Gateway Technologies Pvt Ltd Organisation: Partner Systems Inc. CVE-2024-9990 and CVE-2024-9991 allow remote attackers to execute arbitrary commands. Further information is available from the", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CIAD-2024-0005", + "CVE-2024-9990", + "CVE-2024-9991" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-04-15T10:00:00+00:00", + "provenance": [ + { + "source": "cert-in", + "kind": "document", + "value": "https://cert-in.example/advisory/CIAD-2024-0005", + "decisionReason": null, + "recordedAt": "2024-04-20T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "cert-in", + "kind": "mapping", + "value": "CIAD-2024-0005", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-04-15T10:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://cert-in.example/advisory/CIAD-2024-0005", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "cert-in", + "summary": null, + "url": "https://cert-in.example/advisory/CIAD-2024-0005" + }, + { + "kind": "reference", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://vendor.example.com/advisories/example-gateway-bulletin", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://vendor.example.com/advisories/example-gateway-bulletin" + }, + { + "kind": "advisory", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9990", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9990", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9990" + }, + { + "kind": "advisory", + "provenance": { + "source": "cert-in", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9991", + "decisionReason": null, + "recordedAt": "2024-04-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9991", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9991" + } + ], + "severity": "high", + "summary": "Example Gateway devices vulnerable to remote code execution (CVE-2024-9990).", + "title": "Multiple vulnerabilities in Example Gateway" +} \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cve.Tests/Fixtures/expected-CVE-2024-0001.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cve.Tests/Fixtures/expected-CVE-2024-0001.json index 0abdfa988..a1a27db01 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cve.Tests/Fixtures/expected-CVE-2024-0001.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Cve.Tests/Fixtures/expected-CVE-2024-0001.json @@ -1,221 +1,224 @@ -{ - "advisoryKey": "CVE-2024-0001", - "affectedPackages": [ - { - "type": "vendor", - "identifier": "examplevendor:exampleproduct", - "platform": "linux", - "versionRanges": [ - { - "fixedVersion": "1.2.0", - "introducedVersion": "1.0.0", - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": "version=1.0.0, < 1.2.0", - "exactValue": null, - "fixed": "1.2.0", - "fixedInclusive": false, - "introduced": "1.0.0", - "introducedInclusive": true, - "lastAffected": null, - "lastAffectedInclusive": true, - "style": "range" - }, - "vendorExtensions": { - "vendor": "ExampleVendor", - "product": "ExampleProduct", - "platform": "linux", - "version": "1.0.0", - "lessThan": "1.2.0", - "versionType": "semver" - } - }, - "provenance": { - "source": "cve", - "kind": "affected-range", - "value": "examplevendor:exampleproduct", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "version=1.0.0, < 1.2.0", - "rangeKind": "semver" - }, - { - "fixedVersion": "1.2.0", - "introducedVersion": "1.2.0", - "lastAffectedVersion": "1.2.0", - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": "version=1.2.0", - "exactValue": null, - "fixed": "1.2.0", - "fixedInclusive": false, - "introduced": "1.2.0", - "introducedInclusive": true, - "lastAffected": "1.2.0", - "lastAffectedInclusive": true, - "style": "range" - }, - "vendorExtensions": { - "vendor": "ExampleVendor", - "product": "ExampleProduct", - "platform": "linux", - "version": "1.2.0", - "versionType": "semver" - } - }, - "provenance": { - "source": "cve", - "kind": "affected-range", - "value": "examplevendor:exampleproduct", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "version=1.2.0", - "rangeKind": "semver" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "exact", - "min": null, - "minInclusive": null, - "max": null, - "maxInclusive": null, - "value": "1.2.0", - "notes": "cve:cve-2024-0001:examplevendor:exampleproduct" - }, - { - "scheme": "semver", - "type": "range", - "min": "1.0.0", - "minInclusive": true, - "max": "1.2.0", - "maxInclusive": false, - "value": null, - "notes": "cve:cve-2024-0001:examplevendor:exampleproduct" - } - ], - "statuses": [ - { - "provenance": { - "source": "cve", - "kind": "affected-status", - "value": "examplevendor:exampleproduct", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - "status": "affected" - }, - { - "provenance": { - "source": "cve", - "kind": "affected-status", - "value": "examplevendor:exampleproduct", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - "status": "not_affected" - } - ], - "provenance": [ - { - "source": "cve", - "kind": "affected", - "value": "examplevendor:exampleproduct", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - } - ] - } - ], - "aliases": [ - "CVE-2024-0001", - "GHSA-xxxx-yyyy-zzzz" - ], - "credits": [], - "cvssMetrics": [ - { - "baseScore": 9.8, - "baseSeverity": "critical", - "provenance": { - "source": "cve", - "kind": "cvss", - "value": "cve/CVE-2024-0001", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "version": "3.1" - } - ], - "exploitKnown": false, - "language": "en", - "modified": "2024-09-15T12:00:00+00:00", - "provenance": [ - { - "source": "cve", - "kind": "document", - "value": "cve/CVE-2024-0001", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - { - "source": "cve", - "kind": "mapping", - "value": "CVE-2024-0001", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - } - ], - "published": "2024-09-10T12:00:00+00:00", - "references": [ - { - "kind": "third-party-advisory", - "provenance": { - "source": "cve", - "kind": "reference", - "value": "https://cve.example.com/CVE-2024-0001", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": null, - "summary": null, - "url": "https://cve.example.com/CVE-2024-0001" - }, - { - "kind": "vendor-advisory", - "provenance": { - "source": "cve", - "kind": "reference", - "value": "https://example.com/security/advisory", - "decisionReason": null, - "recordedAt": "2024-10-01T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": "Vendor Advisory", - "summary": null, - "url": "https://example.com/security/advisory" - } - ], - "severity": "critical", - "summary": "An example vulnerability allowing remote attackers to execute arbitrary code.", - "title": "Example Product Remote Code Execution" +{ + "advisoryKey": "CVE-2024-0001", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "examplevendor:exampleproduct", + "platform": "linux", + "versionRanges": [ + { + "fixedVersion": "1.2.0", + "introducedVersion": "1.0.0", + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": "version=1.0.0, < 1.2.0", + "exactValue": null, + "fixed": "1.2.0", + "fixedInclusive": false, + "introduced": "1.0.0", + "introducedInclusive": true, + "lastAffected": null, + "lastAffectedInclusive": true, + "style": "range" + }, + "vendorExtensions": { + "vendor": "ExampleVendor", + "product": "ExampleProduct", + "platform": "linux", + "version": "1.0.0", + "lessThan": "1.2.0", + "versionType": "semver" + } + }, + "provenance": { + "source": "cve", + "kind": "affected-range", + "value": "examplevendor:exampleproduct", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "version=1.0.0, < 1.2.0", + "rangeKind": "semver" + }, + { + "fixedVersion": "1.2.0", + "introducedVersion": "1.2.0", + "lastAffectedVersion": "1.2.0", + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": "version=1.2.0", + "exactValue": "1.2.0", + "fixed": "1.2.0", + "fixedInclusive": false, + "introduced": "1.2.0", + "introducedInclusive": true, + "lastAffected": "1.2.0", + "lastAffectedInclusive": true, + "style": "exact" + }, + "vendorExtensions": { + "vendor": "ExampleVendor", + "product": "ExampleProduct", + "platform": "linux", + "version": "1.2.0", + "versionType": "semver" + } + }, + "provenance": { + "source": "cve", + "kind": "affected-range", + "value": "examplevendor:exampleproduct", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "version=1.2.0", + "rangeKind": "semver" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "exact", + "min": null, + "minInclusive": null, + "max": null, + "maxInclusive": null, + "value": "1.2.0", + "notes": "cve:cve-2024-0001:examplevendor:exampleproduct" + }, + { + "scheme": "semver", + "type": "range", + "min": "1.0.0", + "minInclusive": true, + "max": "1.2.0", + "maxInclusive": false, + "value": null, + "notes": "cve:cve-2024-0001:examplevendor:exampleproduct" + } + ], + "statuses": [ + { + "provenance": { + "source": "cve", + "kind": "affected-status", + "value": "examplevendor:exampleproduct", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + "status": "affected" + }, + { + "provenance": { + "source": "cve", + "kind": "affected-status", + "value": "examplevendor:exampleproduct", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + "status": "not_affected" + } + ], + "provenance": [ + { + "source": "cve", + "kind": "affected", + "value": "examplevendor:exampleproduct", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CVE-2024-0001", + "GHSA-xxxx-yyyy-zzzz" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [ + { + "baseScore": 9.8, + "baseSeverity": "critical", + "provenance": { + "source": "cve", + "kind": "cvss", + "value": "cve/CVE-2024-0001", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "version": "3.1" + } + ], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-09-15T12:00:00+00:00", + "provenance": [ + { + "source": "cve", + "kind": "document", + "value": "cve/CVE-2024-0001", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "cve", + "kind": "mapping", + "value": "CVE-2024-0001", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-09-10T12:00:00+00:00", + "references": [ + { + "kind": "third-party-advisory", + "provenance": { + "source": "cve", + "kind": "reference", + "value": "https://cve.example.com/CVE-2024-0001", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://cve.example.com/CVE-2024-0001" + }, + { + "kind": "vendor-advisory", + "provenance": { + "source": "cve", + "kind": "reference", + "value": "https://example.com/security/advisory", + "decisionReason": null, + "recordedAt": "2024-10-01T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "Vendor Advisory", + "summary": null, + "url": "https://example.com/security/advisory" + } + ], + "severity": "critical", + "summary": "An example vulnerability allowing remote attackers to execute arbitrary code.", + "title": "Example Product Remote Code Execution" } \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Cisa.Tests/IcsCisa/IcsCisaConnectorMappingTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Cisa.Tests/IcsCisa/IcsCisaConnectorMappingTests.cs index 0617ddc5b..95069a129 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Cisa.Tests/IcsCisa/IcsCisaConnectorMappingTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Cisa.Tests/IcsCisa/IcsCisaConnectorMappingTests.cs @@ -91,7 +91,7 @@ public class IcsCisaConnectorMappingTests Assert.Equal("ControlSuite", productPackage.Identifier); var range = Assert.Single(productPackage.VersionRanges); Assert.Equal("product", range.RangeKind); - Assert.Equal("4.2.0", range.RangeExpression); + Assert.Equal("4.2", range.RangeExpression); Assert.NotNull(range.Primitives); Assert.Equal("Example Corp", range.Primitives!.VendorExtensions!["ics.vendors"]); Assert.Equal("ControlSuite", range.Primitives.VendorExtensions!["ics.product"]); @@ -129,7 +129,7 @@ public class IcsCisaConnectorMappingTests var productPackage = Assert.Single(packages); Assert.Equal("Control Suite Firmware", productPackage.Identifier); var range = Assert.Single(productPackage.VersionRanges); - Assert.Equal("1.0.0 - 2.0.0", range.RangeExpression); + Assert.Equal("1.0 - 2.0", range.RangeExpression); Assert.NotNull(range.Primitives); Assert.Equal("ics-cisa:ICSA-25-789-03:control-suite-firmware", range.Provenance.Value); var rule = Assert.Single(productPackage.NormalizedVersions); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Ics/Kaspersky/Fixtures/expected-advisory.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Ics/Kaspersky/Fixtures/expected-advisory.json new file mode 100644 index 000000000..904cc25dd --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Ics/Kaspersky/Fixtures/expected-advisory.json @@ -0,0 +1,557 @@ +{ + "advisoryKey": "acme-controller-2024", + "affectedPackages": [ + { + "type": "ics-vendor", + "identifier": "2024", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "2024" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "2024", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "2024", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "7777 can allow authenticated attackers to execute arbitrary commands", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "7777 can allow authenticated attackers to execute arbitrary commands" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777 can allow authenticated attackers to execute arbitrary commands", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777 can allow authenticated attackers to execute arbitrary commands", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "7777)", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "7777)" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777)", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777)", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "8888", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "8888" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "8888", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "8888", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "ACME Corp", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "ACME Corp" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "ACME Corp Affected models", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "ACME Corp Affected models" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp Affected models", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp Affected models", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "ACME Corp industrial", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "ACME Corp industrial" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp industrial", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp industrial", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "Additional details are provided in CVE", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "Additional details are provided in CVE" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Additional details are provided in CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Additional details are provided in CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "Exploitation of CVE", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "Exploitation of CVE" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Exploitation of CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Exploitation of CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "Vendor", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "Vendor" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Vendor", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Vendor", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "X100, X200", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "X100, X200" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "X100, X200", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "X100, X200", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CVE-2024-7777", + "CVE-2024-8888", + "acme-controller-2024" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-10-15T10:00:00+00:00", + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "document", + "value": "https://ics-cert.example/advisories/acme-controller-2024/", + "decisionReason": null, + "recordedAt": "2024-10-20T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "ics-kaspersky", + "kind": "mapping", + "value": "acme-controller-2024", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-10-15T10:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "ics-kaspersky", + "kind": "reference", + "value": "https://ics-cert.example/advisories/acme-controller-2024/", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "kaspersky-ics", + "summary": null, + "url": "https://ics-cert.example/advisories/acme-controller-2024/" + }, + { + "kind": "advisory", + "provenance": { + "source": "ics-kaspersky", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-7777", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-7777", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-7777" + }, + { + "kind": "advisory", + "provenance": { + "source": "ics-kaspersky", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-8888", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-8888", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-8888" + } + ], + "severity": null, + "summary": "ACME Corp industrial controllers allow remote compromise (CVE-2024-7777).", + "title": "ACME Corp controllers multiple vulnerabilities" +} \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Kaspersky/Fixtures/expected-advisory.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Kaspersky/Fixtures/expected-advisory.json index b2985542b..904cc25dd 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Kaspersky/Fixtures/expected-advisory.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ics.Kaspersky.Tests/Kaspersky/Fixtures/expected-advisory.json @@ -1,515 +1,557 @@ -{ - "advisoryKey": "acme-controller-2024", - "affectedPackages": [ - { - "identifier": "2024", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "2024" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "2024" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "2024" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "7777 can allow authenticated attackers to execute arbitrary commands", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "7777 can allow authenticated attackers to execute arbitrary commands" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "7777 can allow authenticated attackers to execute arbitrary commands" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "7777 can allow authenticated attackers to execute arbitrary commands" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "7777)", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "7777)" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "7777)" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "7777)" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "8888", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "8888" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "8888" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "8888" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "ACME Corp", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "ACME Corp" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "ACME Corp" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "ACME Corp" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "ACME Corp Affected models", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "ACME Corp Affected models" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "ACME Corp Affected models" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "ACME Corp Affected models" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "ACME Corp industrial", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "ACME Corp industrial" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "ACME Corp industrial" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "ACME Corp industrial" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "Additional details are provided in CVE", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "Additional details are provided in CVE" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "Additional details are provided in CVE" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "Additional details are provided in CVE" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "Exploitation of CVE", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "Exploitation of CVE" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "Exploitation of CVE" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "Exploitation of CVE" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "Vendor", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "Vendor" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "Vendor" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "Vendor" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "X100, X200", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "X100, X200" - } - ], - "statuses": [], - "type": "ics-vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "ics.vendor": "X100, X200" - } - }, - "provenance": { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "X100, X200" - }, - "rangeExpression": null, - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CVE-2024-7777", - "CVE-2024-8888", - "acme-controller-2024" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": "2024-10-15T10:00:00+00:00", - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-10-20T00:00:00+00:00", - "source": "ics-kaspersky", - "value": "https://ics-cert.example/advisories/acme-controller-2024/" - }, - { - "fieldMask": [], - "kind": "mapping", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "acme-controller-2024" - } - ], - "published": "2024-10-15T10:00:00+00:00", - "references": [ - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "https://ics-cert.example/advisories/acme-controller-2024/" - }, - "sourceTag": "kaspersky-ics", - "summary": null, - "url": "https://ics-cert.example/advisories/acme-controller-2024/" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-7777" - }, - "sourceTag": "CVE-2024-7777", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-7777" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-10-20T00:01:00+00:00", - "source": "ics-kaspersky", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-8888" - }, - "sourceTag": "CVE-2024-8888", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-8888" - } - ], - "severity": null, - "summary": "ACME Corp industrial controllers allow remote compromise (CVE-2024-7777).", - "title": "ACME Corp controllers multiple vulnerabilities" +{ + "advisoryKey": "acme-controller-2024", + "affectedPackages": [ + { + "type": "ics-vendor", + "identifier": "2024", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "2024" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "2024", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "2024", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "7777 can allow authenticated attackers to execute arbitrary commands", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "7777 can allow authenticated attackers to execute arbitrary commands" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777 can allow authenticated attackers to execute arbitrary commands", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777 can allow authenticated attackers to execute arbitrary commands", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "7777)", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "7777)" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777)", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "7777)", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "8888", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "8888" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "8888", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "8888", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "ACME Corp", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "ACME Corp" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "ACME Corp Affected models", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "ACME Corp Affected models" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp Affected models", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp Affected models", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "ACME Corp industrial", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "ACME Corp industrial" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp industrial", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "ACME Corp industrial", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "Additional details are provided in CVE", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "Additional details are provided in CVE" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Additional details are provided in CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Additional details are provided in CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "Exploitation of CVE", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "Exploitation of CVE" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Exploitation of CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Exploitation of CVE", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "Vendor", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "Vendor" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Vendor", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "Vendor", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "X100, X200", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "ics.vendor": "X100, X200" + } + }, + "provenance": { + "source": "ics-kaspersky", + "kind": "affected", + "value": "X100, X200", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": null, + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "affected", + "value": "X100, X200", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CVE-2024-7777", + "CVE-2024-8888", + "acme-controller-2024" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-10-15T10:00:00+00:00", + "provenance": [ + { + "source": "ics-kaspersky", + "kind": "document", + "value": "https://ics-cert.example/advisories/acme-controller-2024/", + "decisionReason": null, + "recordedAt": "2024-10-20T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "ics-kaspersky", + "kind": "mapping", + "value": "acme-controller-2024", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-10-15T10:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "ics-kaspersky", + "kind": "reference", + "value": "https://ics-cert.example/advisories/acme-controller-2024/", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "kaspersky-ics", + "summary": null, + "url": "https://ics-cert.example/advisories/acme-controller-2024/" + }, + { + "kind": "advisory", + "provenance": { + "source": "ics-kaspersky", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-7777", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-7777", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-7777" + }, + { + "kind": "advisory", + "provenance": { + "source": "ics-kaspersky", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-8888", + "decisionReason": null, + "recordedAt": "2024-10-20T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-8888", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-8888" + } + ], + "severity": null, + "summary": "ACME Corp industrial controllers allow remote compromise (CVE-2024-7777).", + "title": "ACME Corp controllers multiple vulnerabilities" } \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Jvn.Tests/Jvn/Fixtures/expected-advisory.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Jvn.Tests/Jvn/Fixtures/expected-advisory.json index f706a590f..9cc73007f 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Jvn.Tests/Jvn/Fixtures/expected-advisory.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Jvn.Tests/Jvn/Fixtures/expected-advisory.json @@ -1,87 +1,97 @@ -{ - "advisoryKey": "JVNDB-2024-123456", - "affectedPackages": [], - "aliases": [ - "CVE-2024-5555", - "JVNDB-2024-123456" - ], - "cvssMetrics": [ - { - "baseScore": 8.8, - "baseSeverity": "high", - "provenance": { - "fieldMask": [], - "kind": "cvss", - "recordedAt": "2024-03-10T00:01:00+00:00", - "source": "jvn", - "value": "Base" - }, - "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", - "version": "3.1" - } - ], - "exploitKnown": false, - "language": "en", - "modified": "2024-03-10T02:30:00+00:00", - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-03-10T00:00:00+00:00", - "source": "jvn", - "value": "https://jvndb.jvn.jp/myjvn?method=getVulnDetailInfo&feed=hnd&lang=en&vulnId=JVNDB-2024-123456" - }, - { - "fieldMask": [], - "kind": "mapping", - "recordedAt": "2024-03-10T00:01:00+00:00", - "source": "jvn", - "value": "JVNDB-2024-123456" - } - ], - "published": "2024-03-09T02:00:00+00:00", - "references": [ - { - "kind": "weakness", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-03-10T00:01:00+00:00", - "source": "jvn", - "value": "https://cwe.mitre.org/data/definitions/287.html" - }, - "sourceTag": "CWE-287", - "summary": "JVNDB", - "url": "https://cwe.mitre.org/data/definitions/287.html" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-03-10T00:01:00+00:00", - "source": "jvn", - "value": "https://vendor.example.com/advisories/EX-2024-01" - }, - "sourceTag": "EX-2024-01", - "summary": "Example ICS Vendor Advisory", - "url": "https://vendor.example.com/advisories/EX-2024-01" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-03-10T00:01:00+00:00", - "source": "jvn", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-5555" - }, - "sourceTag": "CVE-2024-5555", - "summary": "Common Vulnerabilities and Exposures (CVE)", - "url": "https://www.cve.org/CVERecord?id=CVE-2024-5555" - } - ], - "severity": "high", - "summary": "Imaginary ICS Controller provided by Example Industrial Corporation contains an authentication bypass vulnerability.", - "title": "Example vulnerability in Imaginary ICS Controller" +{ + "advisoryKey": "JVNDB-2024-123456", + "affectedPackages": [], + "aliases": [ + "CVE-2024-5555", + "JVNDB-2024-123456" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [ + { + "baseScore": 8.8, + "baseSeverity": "high", + "provenance": { + "source": "jvn", + "kind": "cvss", + "value": "Base", + "decisionReason": null, + "recordedAt": "2024-03-10T00:01:00+00:00", + "fieldMask": [] + }, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", + "version": "3.1" + } + ], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-03-10T02:30:00+00:00", + "provenance": [ + { + "source": "jvn", + "kind": "document", + "value": "https://jvndb.jvn.jp/myjvn?method=getVulnDetailInfo&feed=hnd&lang=en&vulnId=JVNDB-2024-123456", + "decisionReason": null, + "recordedAt": "2024-03-10T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "jvn", + "kind": "mapping", + "value": "JVNDB-2024-123456", + "decisionReason": null, + "recordedAt": "2024-03-10T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-03-09T02:00:00+00:00", + "references": [ + { + "kind": "weakness", + "provenance": { + "source": "jvn", + "kind": "reference", + "value": "https://cwe.mitre.org/data/definitions/287.html", + "decisionReason": null, + "recordedAt": "2024-03-10T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CWE-287", + "summary": "JVNDB", + "url": "https://cwe.mitre.org/data/definitions/287.html" + }, + { + "kind": "advisory", + "provenance": { + "source": "jvn", + "kind": "reference", + "value": "https://vendor.example.com/advisories/EX-2024-01", + "decisionReason": null, + "recordedAt": "2024-03-10T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "EX-2024-01", + "summary": "Example ICS Vendor Advisory", + "url": "https://vendor.example.com/advisories/EX-2024-01" + }, + { + "kind": "advisory", + "provenance": { + "source": "jvn", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-5555", + "decisionReason": null, + "recordedAt": "2024-03-10T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-5555", + "summary": "Common Vulnerabilities and Exposures (CVE)", + "url": "https://www.cve.org/CVERecord?id=CVE-2024-5555" + } + ], + "severity": "high", + "summary": "Imaginary ICS Controller provided by Example Industrial Corporation contains an authentication bypass vulnerability.", + "title": "Example vulnerability in Imaginary ICS Controller" } \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-advisories.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-advisories.snapshot.json index a4ba5cefb..4bde86465 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-advisories.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-advisories.snapshot.json @@ -1,335 +1,338 @@ -[ - { - "advisoryKey": "BDU:2025-00001", - "affectedPackages": [ - { - "type": "vendor", - "identifier": "ООО «1С-Софт» 1С:Предприятие", - "platform": null, - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": null, - "provenance": { - "source": "ru-bdu", - "kind": "package-range", - "value": "8.2.19.116", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[].versionranges[]" - ] - }, - "rangeExpression": "8.2.19.116", - "rangeKind": "string" - } - ], - "normalizedVersions": [ - { - "scheme": "ru-bdu.raw", - "type": "exact", - "min": null, - "minInclusive": null, - "max": null, - "maxInclusive": null, - "value": "8.2.19.116", - "notes": null - } - ], - "statuses": [ - { - "provenance": { - "source": "ru-bdu", - "kind": "package-status", - "value": "Подтверждена производителем", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[].statuses[]" - ] - }, - "status": "affected" - }, - { - "provenance": { - "source": "ru-bdu", - "kind": "package-fix-status", - "value": "Уязвимость устранена", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[].statuses[]" - ] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "ru-bdu", - "kind": "package", - "value": "ООО «1С-Софт» 1С:Предприятие", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[]" - ] - } - ] - }, - { - "type": "vendor", - "identifier": "ООО «1С-Софт» 1С:Предприятие", - "platform": "Windows", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": null, - "provenance": { - "source": "ru-bdu", - "kind": "package-range", - "value": "8.2.18.96", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[].versionranges[]" - ] - }, - "rangeExpression": "8.2.18.96", - "rangeKind": "string" - } - ], - "normalizedVersions": [ - { - "scheme": "ru-bdu.raw", - "type": "exact", - "min": null, - "minInclusive": null, - "max": null, - "maxInclusive": null, - "value": "8.2.18.96", - "notes": null - } - ], - "statuses": [ - { - "provenance": { - "source": "ru-bdu", - "kind": "package-status", - "value": "Подтверждена производителем", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[].statuses[]" - ] - }, - "status": "affected" - }, - { - "provenance": { - "source": "ru-bdu", - "kind": "package-fix-status", - "value": "Уязвимость устранена", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[].statuses[]" - ] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "ru-bdu", - "kind": "package", - "value": "ООО «1С-Софт» 1С:Предприятие", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "affectedpackages[]" - ] - } - ] - } - ], - "aliases": [ - "BDU:2025-00001", - "CVE-2009-3555", - "CVE-2015-0206", - "PT-2015-0206" - ], - "credits": [], - "cvssMetrics": [ - { - "baseScore": 7.5, - "baseSeverity": "high", - "provenance": { - "source": "ru-bdu", - "kind": "cvss", - "value": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "cvssmetrics[]" - ] - }, - "vector": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P", - "version": "2.0" - }, - { - "baseScore": 9.8, - "baseSeverity": "critical", - "provenance": { - "source": "ru-bdu", - "kind": "cvss", - "value": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "cvssmetrics[]" - ] - }, - "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "version": "3.1" - } - ], - "exploitKnown": true, - "language": "ru", - "modified": "2013-01-12T00:00:00+00:00", - "provenance": [ - { - "source": "ru-bdu", - "kind": "advisory", - "value": "BDU:2025-00001", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "advisory" - ] - } - ], - "published": "2013-01-12T00:00:00+00:00", - "references": [ - { - "kind": "source", - "provenance": { - "source": "ru-bdu", - "kind": "reference", - "value": "http://mirror.example/ru-bdu/BDU-2025-00001", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "ru-bdu", - "summary": null, - "url": "http://mirror.example/ru-bdu/BDU-2025-00001" - }, - { - "kind": "source", - "provenance": { - "source": "ru-bdu", - "kind": "reference", - "value": "https://advisories.example/BDU-2025-00001", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "ru-bdu", - "summary": null, - "url": "https://advisories.example/BDU-2025-00001" - }, - { - "kind": "details", - "provenance": { - "source": "ru-bdu", - "kind": "reference", - "value": "https://bdu.fstec.ru/vul/2025-00001", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "ru-bdu", - "summary": null, - "url": "https://bdu.fstec.ru/vul/2025-00001" - }, - { - "kind": "cwe", - "provenance": { - "source": "ru-bdu", - "kind": "reference", - "value": "https://cwe.mitre.org/data/definitions/310.html", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "cwe", - "summary": "Проблемы использования криптографии", - "url": "https://cwe.mitre.org/data/definitions/310.html" - }, - { - "kind": "cve", - "provenance": { - "source": "ru-bdu", - "kind": "reference", - "value": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "cve", - "summary": "CVE-2009-3555", - "url": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555" - }, - { - "kind": "cve", - "provenance": { - "source": "ru-bdu", - "kind": "reference", - "value": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "cve", - "summary": "CVE-2015-0206", - "url": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206" - }, - { - "kind": "external", - "provenance": { - "source": "ru-bdu", - "kind": "reference", - "value": "https://ptsecurity.com/PT-2015-0206", - "decisionReason": null, - "recordedAt": "2025-10-14T08:00:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "positivetechnologiesadvisory", - "summary": "PT-2015-0206", - "url": "https://ptsecurity.com/PT-2015-0206" - } - ], - "severity": "critical", - "summary": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.", - "title": "Множественные уязвимости криптопровайдера" - } +[ + { + "advisoryKey": "BDU:2025-00001", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "ООО «1С-Софт» 1С:Предприятие", + "platform": null, + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": null, + "provenance": { + "source": "ru-bdu", + "kind": "package-range", + "value": "8.2.19.116", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[].versionranges[]" + ] + }, + "rangeExpression": "8.2.19.116", + "rangeKind": "string" + } + ], + "normalizedVersions": [ + { + "scheme": "ru-bdu.raw", + "type": "exact", + "min": null, + "minInclusive": null, + "max": null, + "maxInclusive": null, + "value": "8.2.19.116", + "notes": null + } + ], + "statuses": [ + { + "provenance": { + "source": "ru-bdu", + "kind": "package-status", + "value": "Подтверждена производителем", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[].statuses[]" + ] + }, + "status": "affected" + }, + { + "provenance": { + "source": "ru-bdu", + "kind": "package-fix-status", + "value": "Уязвимость устранена", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[].statuses[]" + ] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "ru-bdu", + "kind": "package", + "value": "ООО «1С-Софт» 1С:Предприятие", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[]" + ] + } + ] + }, + { + "type": "vendor", + "identifier": "ООО «1С-Софт» 1С:Предприятие", + "platform": "Windows", + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": null, + "provenance": { + "source": "ru-bdu", + "kind": "package-range", + "value": "8.2.18.96", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[].versionranges[]" + ] + }, + "rangeExpression": "8.2.18.96", + "rangeKind": "string" + } + ], + "normalizedVersions": [ + { + "scheme": "ru-bdu.raw", + "type": "exact", + "min": null, + "minInclusive": null, + "max": null, + "maxInclusive": null, + "value": "8.2.18.96", + "notes": null + } + ], + "statuses": [ + { + "provenance": { + "source": "ru-bdu", + "kind": "package-status", + "value": "Подтверждена производителем", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[].statuses[]" + ] + }, + "status": "affected" + }, + { + "provenance": { + "source": "ru-bdu", + "kind": "package-fix-status", + "value": "Уязвимость устранена", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[].statuses[]" + ] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "ru-bdu", + "kind": "package", + "value": "ООО «1С-Софт» 1С:Предприятие", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "affectedpackages[]" + ] + } + ] + } + ], + "aliases": [ + "BDU:2025-00001", + "CVE-2009-3555", + "CVE-2015-0206", + "PT-2015-0206" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [ + { + "baseScore": 7.5, + "baseSeverity": "high", + "provenance": { + "source": "ru-bdu", + "kind": "cvss", + "value": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "cvssmetrics[]" + ] + }, + "vector": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P", + "version": "2.0" + }, + { + "baseScore": 9.8, + "baseSeverity": "critical", + "provenance": { + "source": "ru-bdu", + "kind": "cvss", + "value": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "cvssmetrics[]" + ] + }, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "version": "3.1" + } + ], + "cwes": [], + "description": null, + "exploitKnown": true, + "language": "ru", + "modified": "2013-01-12T00:00:00+00:00", + "provenance": [ + { + "source": "ru-bdu", + "kind": "advisory", + "value": "BDU:2025-00001", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "advisory" + ] + } + ], + "published": "2013-01-12T00:00:00+00:00", + "references": [ + { + "kind": "source", + "provenance": { + "source": "ru-bdu", + "kind": "reference", + "value": "http://mirror.example/ru-bdu/BDU-2025-00001", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "ru-bdu", + "summary": null, + "url": "http://mirror.example/ru-bdu/BDU-2025-00001" + }, + { + "kind": "source", + "provenance": { + "source": "ru-bdu", + "kind": "reference", + "value": "https://advisories.example/BDU-2025-00001", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "ru-bdu", + "summary": null, + "url": "https://advisories.example/BDU-2025-00001" + }, + { + "kind": "details", + "provenance": { + "source": "ru-bdu", + "kind": "reference", + "value": "https://bdu.fstec.ru/vul/2025-00001", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "ru-bdu", + "summary": null, + "url": "https://bdu.fstec.ru/vul/2025-00001" + }, + { + "kind": "cwe", + "provenance": { + "source": "ru-bdu", + "kind": "reference", + "value": "https://cwe.mitre.org/data/definitions/310.html", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "cwe", + "summary": "Проблемы использования криптографии", + "url": "https://cwe.mitre.org/data/definitions/310.html" + }, + { + "kind": "cve", + "provenance": { + "source": "ru-bdu", + "kind": "reference", + "value": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "cve", + "summary": "CVE-2009-3555", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555" + }, + { + "kind": "cve", + "provenance": { + "source": "ru-bdu", + "kind": "reference", + "value": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "cve", + "summary": "CVE-2015-0206", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206" + }, + { + "kind": "external", + "provenance": { + "source": "ru-bdu", + "kind": "reference", + "value": "https://ptsecurity.com/PT-2015-0206", + "decisionReason": null, + "recordedAt": "2025-10-14T08:00:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "positivetechnologiesadvisory", + "summary": "PT-2015-0206", + "url": "https://ptsecurity.com/PT-2015-0206" + } + ], + "severity": "critical", + "summary": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.", + "title": "Множественные уязвимости криптопровайдера" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-documents.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-documents.snapshot.json index 638758f42..750704fff 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-documents.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-documents.snapshot.json @@ -1,11 +1,11 @@ -[ - { - "metadata": { - "ru-bdu.identifier": "BDU:2025-00001", - "ru-bdu.name": "Множественные уязвимости криптопровайдера" - }, - "sha256": "c43df9c4a75a74b281ff09122bb8f63096a0a73b30df74d73c3bc997019bd4d4", - "status": "mapped", - "uri": "https://bdu.fstec.ru/vul/2025-00001" - } +[ + { + "metadata": { + "ru-bdu.identifier": "BDU:2025-00001", + "ru-bdu.name": "Множественные уязвимости криптопровайдера" + }, + "sha256": "c43df9c4a75a74b281ff09122bb8f63096a0a73b30df74d73c3bc997019bd4d4", + "status": "mapped", + "uri": "https://bdu.fstec.ru/vul/2025-00001" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-dtos.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-dtos.snapshot.json index cd03c5c19..26314147a 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-dtos.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-dtos.snapshot.json @@ -1,86 +1,86 @@ -[ - { - "documentUri": "https://bdu.fstec.ru/vul/2025-00001", - "payload": { - "identifier": "BDU:2025-00001", - "name": "Множественные уязвимости криптопровайдера", - "description": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.", - "solution": "Установить обновление 8.2.19.116 защищённого комплекса.", - "identifyDate": "2013-01-12T00:00:00+00:00", - "severityText": "Высокий уровень опасности (базовая оценка CVSS 2.0 составляет 7,5)", - "cvssVector": "AV:N/AC:L/Au:N/C:P/I:P/A:P", - "cvssScore": 7.5, - "cvss3Vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "cvss3Score": 9.8, - "exploitStatus": "Существует в открытом доступе", - "incidentCount": 0, - "fixStatus": "Уязвимость устранена", - "vulStatus": "Подтверждена производителем", - "vulClass": "Уязвимость кода", - "vulState": "Опубликована", - "other": "Язык разработки ПО – С", - "software": [ - { - "vendor": "ООО «1С-Софт»", - "name": "1С:Предприятие", - "version": "8.2.18.96", - "platform": "Windows", - "types": [ - "Прикладное ПО информационных систем" - ] - }, - { - "vendor": "ООО «1С-Софт»", - "name": "1С:Предприятие", - "version": "8.2.19.116", - "platform": "Не указана", - "types": [ - "Прикладное ПО информационных систем" - ] - } - ], - "environment": [ - { - "vendor": "Microsoft Corp", - "name": "Windows", - "version": "-", - "platform": "64-bit" - }, - { - "vendor": "Microsoft Corp", - "name": "Windows", - "version": "-", - "platform": "32-bit" - } - ], - "cwes": [ - { - "identifier": "CWE-310", - "name": "Проблемы использования криптографии" - } - ], - "sources": [ - "https://advisories.example/BDU-2025-00001", - "http://mirror.example/ru-bdu/BDU-2025-00001" - ], - "identifiers": [ - { - "type": "CVE", - "value": "CVE-2015-0206", - "link": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206" - }, - { - "type": "CVE", - "value": "CVE-2009-3555", - "link": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555" - }, - { - "type": "Positive Technologies Advisory", - "value": "PT-2015-0206", - "link": "https://ptsecurity.com/PT-2015-0206" - } - ] - }, - "schemaVersion": "ru-bdu.v1" - } +[ + { + "documentUri": "https://bdu.fstec.ru/vul/2025-00001", + "payload": { + "identifier": "BDU:2025-00001", + "name": "Множественные уязвимости криптопровайдера", + "description": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.", + "solution": "Установить обновление 8.2.19.116 защищённого комплекса.", + "identifyDate": "2013-01-12T00:00:00+00:00", + "severityText": "Высокий уровень опасности (базовая оценка CVSS 2.0 составляет 7,5)", + "cvssVector": "AV:N/AC:L/Au:N/C:P/I:P/A:P", + "cvssScore": 7.5, + "cvss3Vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "cvss3Score": 9.8, + "exploitStatus": "Существует в открытом доступе", + "incidentCount": 0, + "fixStatus": "Уязвимость устранена", + "vulStatus": "Подтверждена производителем", + "vulClass": "Уязвимость кода", + "vulState": "Опубликована", + "other": "Язык разработки ПО – С", + "software": [ + { + "vendor": "ООО «1С-Софт»", + "name": "1С:Предприятие", + "version": "8.2.18.96", + "platform": "Windows", + "types": [ + "Прикладное ПО информационных систем" + ] + }, + { + "vendor": "ООО «1С-Софт»", + "name": "1С:Предприятие", + "version": "8.2.19.116", + "platform": "Не указана", + "types": [ + "Прикладное ПО информационных систем" + ] + } + ], + "environment": [ + { + "vendor": "Microsoft Corp", + "name": "Windows", + "version": "-", + "platform": "64-bit" + }, + { + "vendor": "Microsoft Corp", + "name": "Windows", + "version": "-", + "platform": "32-bit" + } + ], + "cwes": [ + { + "identifier": "CWE-310", + "name": "Проблемы использования криптографии" + } + ], + "sources": [ + "https://advisories.example/BDU-2025-00001", + "http://mirror.example/ru-bdu/BDU-2025-00001" + ], + "identifiers": [ + { + "type": "CVE", + "value": "CVE-2015-0206", + "link": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206" + }, + { + "type": "CVE", + "value": "CVE-2009-3555", + "link": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555" + }, + { + "type": "Positive Technologies Advisory", + "value": "PT-2015-0206", + "link": "https://ptsecurity.com/PT-2015-0206" + } + ] + }, + "schemaVersion": "ru-bdu.v1" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-requests.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-requests.snapshot.json index aa6f9c3b1..5560271ea 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-requests.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-requests.snapshot.json @@ -1,11 +1,11 @@ -[ - { - "headers": { - "accept": "application/zip,application/octet-stream,application/x-zip-compressed", - "accept-Language": "ru-RU,ru; q=0.9,en-US; q=0.6,en; q=0.4", - "user-Agent": "StellaOps/Concelier,(+https://stella-ops.org)" - }, - "method": "GET", - "uri": "https://bdu.fstec.ru/files/documents/vulxml.zip" - } +[ + { + "headers": { + "accept": "application/zip,application/octet-stream,application/x-zip-compressed", + "accept-Language": "ru-RU,ru; q=0.9,en-US; q=0.6,en; q=0.4", + "user-Agent": "StellaOps/Concelier,(+https://stella-ops.org)" + }, + "method": "GET", + "uri": "https://bdu.fstec.ru/files/documents/vulxml.zip" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-state.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-state.snapshot.json index 84060d4df..c5d453f32 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-state.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/Fixtures/ru-bdu-state.snapshot.json @@ -1,5 +1,5 @@ -{ - "lastSuccessfulFetch": "2025-10-14T08:00:00.0000000+00:00", - "pendingDocuments": [], - "pendingMappings": [] +{ + "lastSuccessfulFetch": "2025-10-14T08:00:00.0000000+00:00", + "pendingDocuments": [], + "pendingMappings": [] } \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/RuBduConnectorSnapshotTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/RuBduConnectorSnapshotTests.cs index 730b479c6..ec68f406d 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/RuBduConnectorSnapshotTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/RuBduConnectorSnapshotTests.cs @@ -1,303 +1,313 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Driver; -using StellaOps.Concelier.Models; -using StellaOps.Concelier.Connector.Common.Testing; -using StellaOps.Concelier.Connector.Ru.Bdu; -using StellaOps.Concelier.Connector.Ru.Bdu.Configuration; -using StellaOps.Concelier.Connector.Ru.Bdu.Internal; -using StellaOps.Concelier.Storage.Mongo; -using StellaOps.Concelier.Storage.Mongo.Advisories; -using StellaOps.Concelier.Storage.Mongo.Documents; -using StellaOps.Concelier.Storage.Mongo.Dtos; -using StellaOps.Concelier.Testing; -using Xunit; -using Xunit.Sdk; - -namespace StellaOps.Concelier.Connector.Ru.Bdu.Tests; - -[Collection("mongo-fixture")] -public sealed class RuBduConnectorSnapshotTests : IAsyncLifetime -{ - private const string UpdateFixturesVariable = "UPDATE_BDU_FIXTURES"; - private static readonly Uri ArchiveUri = new("https://bdu.fstec.ru/files/documents/vulxml.zip"); - - private readonly MongoIntegrationFixture _fixture; - private ConnectorTestHarness? _harness; - - public RuBduConnectorSnapshotTests(MongoIntegrationFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task FetchParseMap_ProducesDeterministicSnapshots() - { - var harness = await EnsureHarnessAsync(); - harness.Handler.AddResponse(ArchiveUri, BuildArchiveResponse); - - var connector = harness.ServiceProvider.GetRequiredService(); - await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None); - - var stateRepository = harness.ServiceProvider.GetRequiredService(); - var initialState = await stateRepository.TryGetAsync(RuBduConnectorPlugin.SourceName, CancellationToken.None); - Assert.NotNull(initialState); - var cursorBeforeParse = initialState!.Cursor is null ? RuBduCursor.Empty : RuBduCursor.FromBson(initialState.Cursor); - Assert.NotEmpty(cursorBeforeParse.PendingDocuments); - var expectedDocumentIds = cursorBeforeParse.PendingDocuments.ToArray(); - - await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None); - await connector.MapAsync(harness.ServiceProvider, CancellationToken.None); - - var documentsCollection = _fixture.Database.GetCollection(MongoStorageDefaults.Collections.Document); - var documentCount = await documentsCollection.CountDocumentsAsync(Builders.Filter.Empty); - Assert.True(documentCount > 0, "Expected persisted documents after map stage"); - - var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider, expectedDocumentIds); - WriteOrAssertSnapshot(documentsSnapshot, "ru-bdu-documents.snapshot.json"); - - var dtoSnapshot = await BuildDtoSnapshotAsync(harness.ServiceProvider); - WriteOrAssertSnapshot(dtoSnapshot, "ru-bdu-dtos.snapshot.json"); - - var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider); - WriteOrAssertSnapshot(advisoriesSnapshot, "ru-bdu-advisories.snapshot.json"); - - var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider); - WriteOrAssertSnapshot(stateSnapshot, "ru-bdu-state.snapshot.json"); - - var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests); - WriteOrAssertSnapshot(requestsSnapshot, "ru-bdu-requests.snapshot.json"); - - harness.Handler.AssertNoPendingResponses(); - } - - public Task InitializeAsync() => Task.CompletedTask; - - public async Task DisposeAsync() - { - if (_harness is not null) - { - await _harness.DisposeAsync(); - _harness = null; - } - } - - private async Task EnsureHarnessAsync() - { - if (_harness is not null) - { - return _harness; - } - - var initialTime = new DateTimeOffset(2025, 10, 14, 8, 0, 0, TimeSpan.Zero); - var harness = new ConnectorTestHarness(_fixture, initialTime, RuBduOptions.HttpClientName); - await harness.EnsureServiceProviderAsync(services => - { - services.AddLogging(builder => - { - builder.ClearProviders(); - builder.AddProvider(NullLoggerProvider.Instance); - }); - - services.AddRuBduConnector(options => - { - options.BaseAddress = new Uri("https://bdu.fstec.ru/"); - options.DataArchivePath = "files/documents/vulxml.zip"; - options.MaxVulnerabilitiesPerFetch = 25; - options.RequestTimeout = TimeSpan.FromSeconds(30); - var cacheRoot = Path.Combine(Path.GetTempPath(), "stellaops-tests", _fixture.Database.DatabaseNamespace.DatabaseName, "ru-bdu"); - Directory.CreateDirectory(cacheRoot); - options.CacheDirectory = cacheRoot; - }); - - services.Configure(RuBduOptions.HttpClientName, options => - { - options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler); - }); - }); - - _harness = harness; - return harness; - } - - private static HttpResponseMessage BuildArchiveResponse() - { - var archiveBytes = CreateArchiveBytes(); - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(archiveBytes), - }; - response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); - response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 14, 9, 30, 0, TimeSpan.Zero); - response.Content.Headers.ContentLength = archiveBytes.Length; - return response; - } - - private async Task BuildDocumentsSnapshotAsync(IServiceProvider provider, IReadOnlyCollection documentIds) - { - var documentStore = provider.GetRequiredService(); - var records = new List(documentIds.Count); - - foreach (var documentId in documentIds) - { - var record = await documentStore.FindAsync(documentId, CancellationToken.None); - if (record is null) - { - var existing = await _fixture.Database - .GetCollection("documents") - .Find(Builders.Filter.Empty) - .Project(Builders.Projection.Include("Uri")) - .ToListAsync(CancellationToken.None); - var uris = existing - .Select(document => document.GetValue("Uri", BsonValue.Create(string.Empty)).AsString) - .ToArray(); - throw new XunitException($"Document id not found: {documentId}. Known URIs: {string.Join(", ", uris)}"); - } - - records.Add(new - { - record.Uri, - record.Status, - record.Sha256, - Metadata = record.Metadata is null - ? null - : record.Metadata - .OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase) - .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase) - }); - } - - var ordered = records - .OrderBy(static entry => entry?.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal) - .ToArray(); - - return SnapshotSerializer.ToSnapshot(ordered); - } - - private async Task BuildDtoSnapshotAsync(IServiceProvider provider) - { - var dtoStore = provider.GetRequiredService(); - var documentStore = provider.GetRequiredService(); - var records = await dtoStore.GetBySourceAsync(RuBduConnectorPlugin.SourceName, 25, CancellationToken.None); - - var entries = new List(records.Count); - foreach (var record in records.OrderBy(static r => r.DocumentId)) - { - var document = await documentStore.FindAsync(record.DocumentId, CancellationToken.None); - Assert.NotNull(document); - - var payload = BsonTypeMapper.MapToDotNetValue(record.Payload); - entries.Add(new - { - DocumentUri = document!.Uri, - record.SchemaVersion, - Payload = payload, - }); - } - - return SnapshotSerializer.ToSnapshot(entries.OrderBy(static entry => entry.GetType().GetProperty("DocumentUri")!.GetValue(entry)?.ToString(), StringComparer.Ordinal).ToArray()); - } - - private async Task BuildAdvisoriesSnapshotAsync(IServiceProvider provider) - { - var advisoryStore = provider.GetRequiredService(); - var advisories = await advisoryStore.GetRecentAsync(25, CancellationToken.None); - var ordered = advisories - .OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal) - .ToArray(); - return SnapshotSerializer.ToSnapshot(ordered); - } - - private async Task BuildStateSnapshotAsync(IServiceProvider provider) - { - var stateRepository = provider.GetRequiredService(); - var state = await stateRepository.TryGetAsync(RuBduConnectorPlugin.SourceName, CancellationToken.None); - Assert.NotNull(state); - - var cursor = state!.Cursor is null ? RuBduCursor.Empty : RuBduCursor.FromBson(state.Cursor); - var snapshot = new - { - PendingDocuments = cursor.PendingDocuments.Select(static guid => guid.ToString()).OrderBy(static id => id, StringComparer.Ordinal).ToArray(), - PendingMappings = cursor.PendingMappings.Select(static guid => guid.ToString()).OrderBy(static id => id, StringComparer.Ordinal).ToArray(), - LastSuccessfulFetch = cursor.LastSuccessfulFetch?.ToUniversalTime().ToString("O"), - }; - - return SnapshotSerializer.ToSnapshot(snapshot); - } - - private static string BuildRequestsSnapshot(IReadOnlyCollection requests) - { - var ordered = requests - .Select(record => new - { - Method = record.Method.Method, - Uri = record.Uri.ToString(), - Headers = record.Headers - .OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase) - .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.OrdinalIgnoreCase), - }) - .OrderBy(static entry => entry.Uri, StringComparer.Ordinal) - .ToArray(); - - return SnapshotSerializer.ToSnapshot(ordered); - } - - private static string ReadFixtureText(string filename) - { - var path = GetSourceFixturePath(filename); - return File.ReadAllText(path, Encoding.UTF8); - } - - private static byte[] CreateArchiveBytes() - { - var xml = ReadFixtureText("export-sample.xml"); - using var buffer = new MemoryStream(); - using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) - { - var entry = archive.CreateEntry("export/export.xml", CompressionLevel.NoCompression); - entry.LastWriteTime = new DateTimeOffset(2025, 10, 14, 9, 0, 0, TimeSpan.Zero); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - writer.Write(xml); - } - - return buffer.ToArray(); - } - - private static bool ShouldUpdateFixtures() - => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(UpdateFixturesVariable)); - - private static void WriteOrAssertSnapshot(string content, string filename) - { - var path = GetSourceFixturePath(filename); - if (ShouldUpdateFixtures()) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, content, Encoding.UTF8); - } - else - { - Assert.True(File.Exists(path), $"Snapshot '{filename}' is missing. Run {UpdateFixturesVariable}=1 dotnet test to regenerate fixtures."); - var expected = File.ReadAllText(path, Encoding.UTF8); - Assert.Equal(expected, content); - } - } - - private static string GetSourceFixturePath(string relativeName) - => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", relativeName)); -} +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; +using StellaOps.Concelier.Models; +using StellaOps.Concelier.Connector.Common.Testing; +using StellaOps.Concelier.Connector.Ru.Bdu; +using StellaOps.Concelier.Connector.Ru.Bdu.Configuration; +using StellaOps.Concelier.Connector.Ru.Bdu.Internal; +using StellaOps.Concelier.Storage.Mongo; +using StellaOps.Concelier.Storage.Mongo.Advisories; +using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Storage.Mongo.Dtos; +using StellaOps.Concelier.Testing; +using StellaOps.Cryptography.DependencyInjection; +using Xunit; +using Xunit.Sdk; + +namespace StellaOps.Concelier.Connector.Ru.Bdu.Tests; + +[Collection("mongo-fixture")] +public sealed class RuBduConnectorSnapshotTests : IAsyncLifetime +{ + private const string UpdateFixturesVariable = "UPDATE_BDU_FIXTURES"; + private static readonly Uri ArchiveUri = new("https://bdu.fstec.ru/files/documents/vulxml.zip"); + + private readonly MongoIntegrationFixture _fixture; + private ConnectorTestHarness? _harness; + + public RuBduConnectorSnapshotTests(MongoIntegrationFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task FetchParseMap_ProducesDeterministicSnapshots() + { + var harness = await EnsureHarnessAsync(); + harness.Handler.AddResponse(ArchiveUri, BuildArchiveResponse); + + var connector = harness.ServiceProvider.GetRequiredService(); + await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None); + + var stateRepository = harness.ServiceProvider.GetRequiredService(); + var initialState = await stateRepository.TryGetAsync(RuBduConnectorPlugin.SourceName, CancellationToken.None); + Assert.NotNull(initialState); + var cursorBeforeParse = initialState!.Cursor is null ? RuBduCursor.Empty : RuBduCursor.FromBson(initialState.Cursor); + Assert.NotEmpty(cursorBeforeParse.PendingDocuments); + var expectedDocumentIds = cursorBeforeParse.PendingDocuments.ToArray(); + + await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None); + await connector.MapAsync(harness.ServiceProvider, CancellationToken.None); + + var documentsCollection = _fixture.Database.GetCollection(MongoStorageDefaults.Collections.Document); + var documentCount = await documentsCollection.CountDocumentsAsync(Builders.Filter.Empty); + Assert.True(documentCount > 0, "Expected persisted documents after map stage"); + + var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider, expectedDocumentIds); + WriteOrAssertSnapshot(documentsSnapshot, "ru-bdu-documents.snapshot.json"); + + var dtoSnapshot = await BuildDtoSnapshotAsync(harness.ServiceProvider); + WriteOrAssertSnapshot(dtoSnapshot, "ru-bdu-dtos.snapshot.json"); + + var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider); + WriteOrAssertSnapshot(advisoriesSnapshot, "ru-bdu-advisories.snapshot.json"); + + var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider); + WriteOrAssertSnapshot(stateSnapshot, "ru-bdu-state.snapshot.json"); + + var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests); + WriteOrAssertSnapshot(requestsSnapshot, "ru-bdu-requests.snapshot.json"); + + harness.Handler.AssertNoPendingResponses(); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_harness is not null) + { + await _harness.DisposeAsync(); + _harness = null; + } + } + + private async Task EnsureHarnessAsync() + { + if (_harness is not null) + { + return _harness; + } + + var initialTime = new DateTimeOffset(2025, 10, 14, 8, 0, 0, TimeSpan.Zero); + var harness = new ConnectorTestHarness(_fixture, initialTime, RuBduOptions.HttpClientName); + await harness.EnsureServiceProviderAsync(services => + { + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddProvider(NullLoggerProvider.Instance); + }); + + services.AddStellaOpsCrypto(); + services.AddRuBduConnector(options => + { + options.BaseAddress = new Uri("https://bdu.fstec.ru/"); + options.DataArchivePath = "files/documents/vulxml.zip"; + options.MaxVulnerabilitiesPerFetch = 25; + options.RequestTimeout = TimeSpan.FromSeconds(30); + var cacheRoot = Path.Combine(Path.GetTempPath(), "stellaops-tests", _fixture.Database.DatabaseNamespace.DatabaseName, "ru-bdu"); + Directory.CreateDirectory(cacheRoot); + options.CacheDirectory = cacheRoot; + }); + + services.Configure(RuBduOptions.HttpClientName, options => + { + options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler); + }); + }); + + _harness = harness; + return harness; + } + + private static HttpResponseMessage BuildArchiveResponse() + { + var archiveBytes = CreateArchiveBytes(); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archiveBytes), + }; + response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 14, 9, 30, 0, TimeSpan.Zero); + response.Content.Headers.ContentLength = archiveBytes.Length; + return response; + } + + private async Task BuildDocumentsSnapshotAsync(IServiceProvider provider, IReadOnlyCollection documentIds) + { + var documentStore = provider.GetRequiredService(); + var records = new List(documentIds.Count); + + foreach (var documentId in documentIds) + { + var record = await documentStore.FindAsync(documentId, CancellationToken.None); + if (record is null) + { + var existing = await _fixture.Database + .GetCollection("documents") + .Find(Builders.Filter.Empty) + .Project(Builders.Projection.Include("Uri")) + .ToListAsync(CancellationToken.None); + var uris = existing + .Select(document => document.GetValue("Uri", BsonValue.Create(string.Empty)).AsString) + .ToArray(); + throw new XunitException($"Document id not found: {documentId}. Known URIs: {string.Join(", ", uris)}"); + } + + records.Add(new + { + record.Uri, + record.Status, + record.Sha256, + Metadata = record.Metadata is null + ? null + : record.Metadata + .OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase) + }); + } + + var ordered = records + .OrderBy(static entry => entry?.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal) + .ToArray(); + + return SnapshotSerializer.ToSnapshot(ordered); + } + + private async Task BuildDtoSnapshotAsync(IServiceProvider provider) + { + var dtoStore = provider.GetRequiredService(); + var documentStore = provider.GetRequiredService(); + var records = await dtoStore.GetBySourceAsync(RuBduConnectorPlugin.SourceName, 25, CancellationToken.None); + + var entries = new List(records.Count); + foreach (var record in records.OrderBy(static r => r.DocumentId)) + { + var document = await documentStore.FindAsync(record.DocumentId, CancellationToken.None); + Assert.NotNull(document); + + var payload = BsonTypeMapper.MapToDotNetValue(record.Payload); + entries.Add(new + { + DocumentUri = document!.Uri, + record.SchemaVersion, + Payload = payload, + }); + } + + return SnapshotSerializer.ToSnapshot(entries.OrderBy(static entry => entry.GetType().GetProperty("DocumentUri")!.GetValue(entry)?.ToString(), StringComparer.Ordinal).ToArray()); + } + + private async Task BuildAdvisoriesSnapshotAsync(IServiceProvider provider) + { + var advisoryStore = provider.GetRequiredService(); + var advisories = await advisoryStore.GetRecentAsync(25, CancellationToken.None); + var ordered = advisories + .OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal) + .ToArray(); + return SnapshotSerializer.ToSnapshot(ordered); + } + + private async Task BuildStateSnapshotAsync(IServiceProvider provider) + { + var stateRepository = provider.GetRequiredService(); + var state = await stateRepository.TryGetAsync(RuBduConnectorPlugin.SourceName, CancellationToken.None); + Assert.NotNull(state); + + var cursor = state!.Cursor is null ? RuBduCursor.Empty : RuBduCursor.FromBson(state.Cursor); + var snapshot = new + { + PendingDocuments = cursor.PendingDocuments.Select(static guid => guid.ToString()).OrderBy(static id => id, StringComparer.Ordinal).ToArray(), + PendingMappings = cursor.PendingMappings.Select(static guid => guid.ToString()).OrderBy(static id => id, StringComparer.Ordinal).ToArray(), + LastSuccessfulFetch = cursor.LastSuccessfulFetch?.ToUniversalTime().ToString("O"), + }; + + return SnapshotSerializer.ToSnapshot(snapshot); + } + + private static string BuildRequestsSnapshot(IReadOnlyCollection requests) + { + var ordered = requests + .Select(record => new + { + Method = record.Method.Method, + Uri = record.Uri.ToString(), + Headers = record.Headers + .OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.OrdinalIgnoreCase), + }) + .OrderBy(static entry => entry.Uri, StringComparer.Ordinal) + .ToArray(); + + return SnapshotSerializer.ToSnapshot(ordered); + } + + private static string ReadFixtureText(string filename) + { + var path = GetSourceFixturePath(filename); + return File.ReadAllText(path, Encoding.UTF8); + } + + private static byte[] CreateArchiveBytes() + { + var xml = ReadFixtureText("export-sample.xml"); + using var buffer = new MemoryStream(); + using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("export/export.xml", CompressionLevel.NoCompression); + entry.LastWriteTime = new DateTimeOffset(2025, 10, 14, 9, 0, 0, TimeSpan.Zero); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writer.Write(xml); + } + + return buffer.ToArray(); + } + + private static bool ShouldUpdateFixtures() + => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(UpdateFixturesVariable)); + + private static void WriteOrAssertSnapshot(string content, string filename) + { + var path = GetSourceFixturePath(filename); + if (ShouldUpdateFixtures()) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, NormalizeLineEndings(content), Encoding.UTF8); + } + else + { + Assert.True(File.Exists(path), $"Snapshot '{filename}' is missing. Run {UpdateFixturesVariable}=1 dotnet test to regenerate fixtures."); + var expected = File.ReadAllText(path, Encoding.UTF8); + Assert.Equal(NormalizeLineEndings(expected), NormalizeLineEndings(content)); + } + } + + private static string GetSourceFixturePath(string relativeName) + => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", relativeName)); + + private static string NormalizeLineEndings(string value) + { + var normalized = value.Replace("\r\n", "\n", StringComparison.Ordinal); + return normalized.Length > 0 && normalized[0] == '\ufeff' + ? normalized[1..] + : normalized; + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests.csproj index de6617988..e8e070675 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests/StellaOps.Concelier.Connector.Ru.Bdu.Tests.csproj @@ -10,5 +10,6 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/Fixtures/nkcki-advisories.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/Fixtures/nkcki-advisories.snapshot.json index f9299afaa..7d52cebb2 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/Fixtures/nkcki-advisories.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/Fixtures/nkcki-advisories.snapshot.json @@ -1,495 +1,501 @@ -[ - { - "advisoryKey": "BDU:2025-01001", - "affectedPackages": [ - { - "type": "ics-vendor", - "identifier": "SampleVendor SampleGateway", - "platform": "Energy, ICS", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": "2.0", - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": false, - "nevra": null, - "semVer": { - "constraintExpression": ">= 2.0", - "exactValue": null, - "fixed": null, - "fixedInclusive": false, - "introduced": "2.0", - "introducedInclusive": true, - "lastAffected": null, - "lastAffectedInclusive": false, - "style": "greaterThanOrEqual" - }, - "vendorExtensions": null - }, - "provenance": { - "source": "ru-nkcki", - "kind": "package-range", - "value": "SampleVendor SampleGateway >= 2.0 All platforms", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[].versionranges[]" - ] - }, - "rangeExpression": ">= 2.0", - "rangeKind": "semver" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "gte", - "min": "2.0", - "minInclusive": true, - "max": null, - "maxInclusive": null, - "value": null, - "notes": "SampleVendor SampleGateway >= 2.0 All platforms" - } - ], - "statuses": [ - { - "provenance": { - "source": "ru-nkcki", - "kind": "package-status", - "value": "patch_available", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[].statuses[]" - ] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "ru-nkcki", - "kind": "package", - "value": "SampleVendor SampleGateway >= 2.0 All platforms", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[]" - ] - } - ] - }, - { - "type": "ics-vendor", - "identifier": "SampleVendor SampleSCADA", - "platform": "Energy, ICS", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": "4.2", - "primitives": { - "evr": null, - "hasVendorExtensions": false, - "nevra": null, - "semVer": { - "constraintExpression": "<= 4.2", - "exactValue": null, - "fixed": null, - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "4.2", - "lastAffectedInclusive": true, - "style": "lessThanOrEqual" - }, - "vendorExtensions": null - }, - "provenance": { - "source": "ru-nkcki", - "kind": "package-range", - "value": "SampleVendor SampleSCADA <= 4.2", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[].versionranges[]" - ] - }, - "rangeExpression": "<= 4.2", - "rangeKind": "semver" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "lte", - "min": null, - "minInclusive": null, - "max": "4.2", - "maxInclusive": true, - "value": null, - "notes": "SampleVendor SampleSCADA <= 4.2" - } - ], - "statuses": [ - { - "provenance": { - "source": "ru-nkcki", - "kind": "package-status", - "value": "patch_available", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[].statuses[]" - ] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "ru-nkcki", - "kind": "package", - "value": "SampleVendor SampleSCADA <= 4.2", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[]" - ] - } - ] - } - ], - "aliases": [ - "BDU:2025-01001", - "CVE-2025-0101" - ], - "credits": [], - "cvssMetrics": [ - { - "baseScore": 8.5, - "baseSeverity": "high", - "provenance": { - "source": "ru-nkcki", - "kind": "cvss", - "value": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "cvssmetrics[]" - ] - }, - "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", - "version": "3.1" - }, - { - "baseScore": 6.4, - "baseSeverity": "medium", - "provenance": { - "source": "ru-nkcki", - "kind": "cvss", - "value": "CVSS:4.0/AV:N/AC:H/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "cvssmetrics[]" - ] - }, - "vector": "CVSS:4.0/AV:N/AC:H/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H", - "version": "4.0" - } - ], - "exploitKnown": true, - "language": "ru", - "modified": "2025-09-22T00:00:00+00:00", - "provenance": [ - { - "source": "ru-nkcki", - "kind": "advisory", - "value": "BDU:2025-01001", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "advisory" - ] - } - ], - "published": "2025-09-20T00:00:00+00:00", - "references": [ - { - "kind": "details", - "provenance": { - "source": "ru-nkcki", - "kind": "reference", - "value": "https://bdu.fstec.ru/vul/2025-01001", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "bdu", - "summary": null, - "url": "https://bdu.fstec.ru/vul/2025-01001" - }, - { - "kind": "details", - "provenance": { - "source": "ru-nkcki", - "kind": "reference", - "value": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "ru-nkcki", - "summary": null, - "url": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001" - }, - { - "kind": "cwe", - "provenance": { - "source": "ru-nkcki", - "kind": "reference", - "value": "https://cwe.mitre.org/data/definitions/321.html", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "cwe", - "summary": "Use of Hard-coded Cryptographic Key", - "url": "https://cwe.mitre.org/data/definitions/321.html" - }, - { - "kind": "external", - "provenance": { - "source": "ru-nkcki", - "kind": "reference", - "value": "https://vendor.example/advisories/sample-scada", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": null, - "summary": null, - "url": "https://vendor.example/advisories/sample-scada" - } - ], - "severity": "critical", - "summary": "Authenticated RCE in Sample SCADA", - "title": "Authenticated RCE in Sample SCADA" - }, - { - "advisoryKey": "BDU:2024-00011", - "affectedPackages": [ - { - "type": "cpe", - "identifier": "LegacyPanel", - "platform": "Software", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": "2.5", - "primitives": { - "evr": null, - "hasVendorExtensions": false, - "nevra": null, - "semVer": { - "constraintExpression": "<= 2.5", - "exactValue": null, - "fixed": null, - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "2.5", - "lastAffectedInclusive": true, - "style": "lessThanOrEqual" - }, - "vendorExtensions": null - }, - "provenance": { - "source": "ru-nkcki", - "kind": "package-range", - "value": "LegacyPanel 1.0 - 2.5", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[].versionranges[]" - ] - }, - "rangeExpression": "<= 2.5", - "rangeKind": "semver" - }, - { - "fixedVersion": null, - "introducedVersion": "1.0", - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": false, - "nevra": null, - "semVer": { - "constraintExpression": ">= 1.0", - "exactValue": null, - "fixed": null, - "fixedInclusive": false, - "introduced": "1.0", - "introducedInclusive": true, - "lastAffected": null, - "lastAffectedInclusive": false, - "style": "greaterThanOrEqual" - }, - "vendorExtensions": null - }, - "provenance": { - "source": "ru-nkcki", - "kind": "package-range", - "value": "LegacyPanel 1.0 - 2.5", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[].versionranges[]" - ] - }, - "rangeExpression": ">= 1.0", - "rangeKind": "semver" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "gte", - "min": "1.0", - "minInclusive": true, - "max": null, - "maxInclusive": null, - "value": null, - "notes": "LegacyPanel 1.0 - 2.5" - }, - { - "scheme": "semver", - "type": "lte", - "min": null, - "minInclusive": null, - "max": "2.5", - "maxInclusive": true, - "value": null, - "notes": "LegacyPanel 1.0 - 2.5" - } - ], - "statuses": [ - { - "provenance": { - "source": "ru-nkcki", - "kind": "package-status", - "value": "affected", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[].statuses[]" - ] - }, - "status": "affected" - } - ], - "provenance": [ - { - "source": "ru-nkcki", - "kind": "package", - "value": "LegacyPanel 1.0 - 2.5", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "affectedpackages[]" - ] - } - ] - } - ], - "aliases": [ - "BDU:2024-00011" - ], - "credits": [], - "cvssMetrics": [ - { - "baseScore": 8.8, - "baseSeverity": "high", - "provenance": { - "source": "ru-nkcki", - "kind": "cvss", - "value": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "cvssmetrics[]" - ] - }, - "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", - "version": "3.1" - } - ], - "exploitKnown": true, - "language": "ru", - "modified": "2024-08-02T00:00:00+00:00", - "provenance": [ - { - "source": "ru-nkcki", - "kind": "advisory", - "value": "BDU:2024-00011", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "advisory" - ] - } - ], - "published": "2024-08-01T00:00:00+00:00", - "references": [ - { - "kind": "details", - "provenance": { - "source": "ru-nkcki", - "kind": "reference", - "value": "https://bdu.fstec.ru/vul/2024-00011", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "bdu", - "summary": null, - "url": "https://bdu.fstec.ru/vul/2024-00011" - }, - { - "kind": "details", - "provenance": { - "source": "ru-nkcki", - "kind": "reference", - "value": "https://cert.gov.ru/materialy/uyazvimosti/2024-00011", - "decisionReason": null, - "recordedAt": "2025-10-12T00:01:00+00:00", - "fieldMask": [ - "references[]" - ] - }, - "sourceTag": "ru-nkcki", - "summary": null, - "url": "https://cert.gov.ru/materialy/uyazvimosti/2024-00011" - } - ], - "severity": "high", - "summary": "Legacy panel overflow", - "title": "Legacy panel overflow" - } +[ + { + "advisoryKey": "BDU:2025-01001", + "affectedPackages": [ + { + "type": "ics-vendor", + "identifier": "SampleVendor SampleGateway", + "platform": "Energy, ICS", + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": "2.0.0", + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": false, + "nevra": null, + "semVer": { + "constraintExpression": ">= 2.0.0", + "exactValue": null, + "fixed": null, + "fixedInclusive": false, + "introduced": "2.0.0", + "introducedInclusive": true, + "lastAffected": null, + "lastAffectedInclusive": false, + "style": "greaterThanOrEqual" + }, + "vendorExtensions": null + }, + "provenance": { + "source": "ru-nkcki", + "kind": "package-range", + "value": "SampleVendor SampleGateway >= 2.0 All platforms", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[].versionranges[]" + ] + }, + "rangeExpression": ">= 2.0.0", + "rangeKind": "semver" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "gte", + "min": "2.0.0", + "minInclusive": true, + "max": null, + "maxInclusive": null, + "value": null, + "notes": "SampleVendor SampleGateway >= 2.0 All platforms" + } + ], + "statuses": [ + { + "provenance": { + "source": "ru-nkcki", + "kind": "package-status", + "value": "patch_available", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[].statuses[]" + ] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "ru-nkcki", + "kind": "package", + "value": "SampleVendor SampleGateway >= 2.0 All platforms", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[]" + ] + } + ] + }, + { + "type": "ics-vendor", + "identifier": "SampleVendor SampleSCADA", + "platform": "Energy, ICS", + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": "4.2.0", + "primitives": { + "evr": null, + "hasVendorExtensions": false, + "nevra": null, + "semVer": { + "constraintExpression": "<= 4.2.0", + "exactValue": null, + "fixed": null, + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "4.2.0", + "lastAffectedInclusive": true, + "style": "lessThanOrEqual" + }, + "vendorExtensions": null + }, + "provenance": { + "source": "ru-nkcki", + "kind": "package-range", + "value": "SampleVendor SampleSCADA <= 4.2", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[].versionranges[]" + ] + }, + "rangeExpression": "<= 4.2.0", + "rangeKind": "semver" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "lte", + "min": null, + "minInclusive": null, + "max": "4.2.0", + "maxInclusive": true, + "value": null, + "notes": "SampleVendor SampleSCADA <= 4.2" + } + ], + "statuses": [ + { + "provenance": { + "source": "ru-nkcki", + "kind": "package-status", + "value": "patch_available", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[].statuses[]" + ] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "ru-nkcki", + "kind": "package", + "value": "SampleVendor SampleSCADA <= 4.2", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[]" + ] + } + ] + } + ], + "aliases": [ + "BDU:2025-01001", + "CVE-2025-0101" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [ + { + "baseScore": 8.5, + "baseSeverity": "high", + "provenance": { + "source": "ru-nkcki", + "kind": "cvss", + "value": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "cvssmetrics[]" + ] + }, + "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", + "version": "3.1" + }, + { + "baseScore": 6.4, + "baseSeverity": "medium", + "provenance": { + "source": "ru-nkcki", + "kind": "cvss", + "value": "CVSS:4.0/AV:N/AC:H/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "cvssmetrics[]" + ] + }, + "vector": "CVSS:4.0/AV:N/AC:H/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H", + "version": "4.0" + } + ], + "cwes": [], + "description": null, + "exploitKnown": true, + "language": "ru", + "modified": "2025-09-22T00:00:00+00:00", + "provenance": [ + { + "source": "ru-nkcki", + "kind": "advisory", + "value": "BDU:2025-01001", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "advisory" + ] + } + ], + "published": "2025-09-20T00:00:00+00:00", + "references": [ + { + "kind": "details", + "provenance": { + "source": "ru-nkcki", + "kind": "reference", + "value": "https://bdu.fstec.ru/vul/2025-01001", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "bdu", + "summary": null, + "url": "https://bdu.fstec.ru/vul/2025-01001" + }, + { + "kind": "details", + "provenance": { + "source": "ru-nkcki", + "kind": "reference", + "value": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "ru-nkcki", + "summary": null, + "url": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001" + }, + { + "kind": "cwe", + "provenance": { + "source": "ru-nkcki", + "kind": "reference", + "value": "https://cwe.mitre.org/data/definitions/321.html", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "cwe", + "summary": "Use of Hard-coded Cryptographic Key", + "url": "https://cwe.mitre.org/data/definitions/321.html" + }, + { + "kind": "external", + "provenance": { + "source": "ru-nkcki", + "kind": "reference", + "value": "https://vendor.example/advisories/sample-scada", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": null, + "summary": null, + "url": "https://vendor.example/advisories/sample-scada" + } + ], + "severity": "critical", + "summary": "Authenticated RCE in Sample SCADA", + "title": "Authenticated RCE in Sample SCADA" + }, + { + "advisoryKey": "BDU:2024-00011", + "affectedPackages": [ + { + "type": "cpe", + "identifier": "LegacyPanel", + "platform": "Software", + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": "2.5.0", + "primitives": { + "evr": null, + "hasVendorExtensions": false, + "nevra": null, + "semVer": { + "constraintExpression": "<= 2.5.0", + "exactValue": null, + "fixed": null, + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "2.5.0", + "lastAffectedInclusive": true, + "style": "lessThanOrEqual" + }, + "vendorExtensions": null + }, + "provenance": { + "source": "ru-nkcki", + "kind": "package-range", + "value": "LegacyPanel 1.0 - 2.5", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[].versionranges[]" + ] + }, + "rangeExpression": "<= 2.5.0", + "rangeKind": "semver" + }, + { + "fixedVersion": null, + "introducedVersion": "1.0.0", + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": false, + "nevra": null, + "semVer": { + "constraintExpression": ">= 1.0.0", + "exactValue": null, + "fixed": null, + "fixedInclusive": false, + "introduced": "1.0.0", + "introducedInclusive": true, + "lastAffected": null, + "lastAffectedInclusive": false, + "style": "greaterThanOrEqual" + }, + "vendorExtensions": null + }, + "provenance": { + "source": "ru-nkcki", + "kind": "package-range", + "value": "LegacyPanel 1.0 - 2.5", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[].versionranges[]" + ] + }, + "rangeExpression": ">= 1.0.0", + "rangeKind": "semver" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "gte", + "min": "1.0.0", + "minInclusive": true, + "max": null, + "maxInclusive": null, + "value": null, + "notes": "LegacyPanel 1.0 - 2.5" + }, + { + "scheme": "semver", + "type": "lte", + "min": null, + "minInclusive": null, + "max": "2.5.0", + "maxInclusive": true, + "value": null, + "notes": "LegacyPanel 1.0 - 2.5" + } + ], + "statuses": [ + { + "provenance": { + "source": "ru-nkcki", + "kind": "package-status", + "value": "affected", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[].statuses[]" + ] + }, + "status": "affected" + } + ], + "provenance": [ + { + "source": "ru-nkcki", + "kind": "package", + "value": "LegacyPanel 1.0 - 2.5", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "affectedpackages[]" + ] + } + ] + } + ], + "aliases": [ + "BDU:2024-00011" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [ + { + "baseScore": 8.8, + "baseSeverity": "high", + "provenance": { + "source": "ru-nkcki", + "kind": "cvss", + "value": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "cvssmetrics[]" + ] + }, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", + "version": "3.1" + } + ], + "cwes": [], + "description": null, + "exploitKnown": true, + "language": "ru", + "modified": "2024-08-02T00:00:00+00:00", + "provenance": [ + { + "source": "ru-nkcki", + "kind": "advisory", + "value": "BDU:2024-00011", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "advisory" + ] + } + ], + "published": "2024-08-01T00:00:00+00:00", + "references": [ + { + "kind": "details", + "provenance": { + "source": "ru-nkcki", + "kind": "reference", + "value": "https://bdu.fstec.ru/vul/2024-00011", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "bdu", + "summary": null, + "url": "https://bdu.fstec.ru/vul/2024-00011" + }, + { + "kind": "details", + "provenance": { + "source": "ru-nkcki", + "kind": "reference", + "value": "https://cert.gov.ru/materialy/uyazvimosti/2024-00011", + "decisionReason": null, + "recordedAt": "2025-10-12T00:01:00+00:00", + "fieldMask": [ + "references[]" + ] + }, + "sourceTag": "ru-nkcki", + "summary": null, + "url": "https://cert.gov.ru/materialy/uyazvimosti/2024-00011" + } + ], + "severity": "high", + "summary": "Legacy panel overflow", + "title": "Legacy panel overflow" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/RuNkckiConnectorTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/RuNkckiConnectorTests.cs index 97eef970d..2a618c963 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/RuNkckiConnectorTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/RuNkckiConnectorTests.cs @@ -19,12 +19,13 @@ using StellaOps.Concelier.Connector.Common.Http; using StellaOps.Concelier.Connector.Common.Testing; using StellaOps.Concelier.Connector.Ru.Nkcki; using StellaOps.Concelier.Connector.Ru.Nkcki.Configuration; -using StellaOps.Concelier.Storage.Mongo; -using StellaOps.Concelier.Storage.Mongo.Advisories; -using StellaOps.Concelier.Storage.Mongo.Documents; -using StellaOps.Concelier.Testing; -using StellaOps.Concelier.Models; -using MongoDB.Driver; +using StellaOps.Concelier.Storage.Mongo; +using StellaOps.Concelier.Storage.Mongo.Advisories; +using StellaOps.Concelier.Storage.Mongo.Documents; +using StellaOps.Concelier.Testing; +using StellaOps.Concelier.Models; +using MongoDB.Driver; +using StellaOps.Cryptography.DependencyInjection; using Xunit; namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests; @@ -123,14 +124,15 @@ public sealed class RuNkckiConnectorTests : IAsyncLifetime services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); services.AddSingleton(_timeProvider); - services.AddMongoStorage(options => - { - options.ConnectionString = _fixture.Runner.ConnectionString; - options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName; - options.CommandTimeout = TimeSpan.FromSeconds(5); - }); - - services.AddSourceCommon(); + services.AddMongoStorage(options => + { + options.ConnectionString = _fixture.Runner.ConnectionString; + options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName; + options.CommandTimeout = TimeSpan.FromSeconds(5); + }); + + services.AddStellaOpsCrypto(); + services.AddSourceCommon(); services.AddRuNkckiConnector(options => { options.BaseAddress = new Uri("https://cert.gov.ru/"); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests.csproj index 9ad0ed381..f6b39b0e6 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests/StellaOps.Concelier.Connector.Ru.Nkcki.Tests.csproj @@ -10,5 +10,6 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests.csproj index 952005b10..f84b67eb9 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests.csproj @@ -8,8 +8,9 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOpsMirrorConnectorTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOpsMirrorConnectorTests.cs index 617fba8c6..ce368f871 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOpsMirrorConnectorTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOpsMirrorConnectorTests.cs @@ -23,7 +23,8 @@ using StellaOps.Concelier.Storage.Mongo.Advisories; using StellaOps.Concelier.Storage.Mongo.Documents; using StellaOps.Concelier.Storage.Mongo.Dtos; using StellaOps.Concelier.Testing; -using StellaOps.Cryptography; +using StellaOps.Cryptography; +using StellaOps.Cryptography.DependencyInjection; using StellaOps.Concelier.Models; using Xunit; @@ -287,9 +288,7 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime options.CommandTimeout = TimeSpan.FromSeconds(5); }); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => new CryptoProviderRegistry(sp.GetServices())); + services.AddStellaOpsCrypto(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Adobe.Tests/Adobe/Fixtures/adobe-advisories.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Adobe.Tests/Adobe/Fixtures/adobe-advisories.snapshot.json index 58c45da46..24411b79d 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Adobe.Tests/Adobe/Fixtures/adobe-advisories.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Adobe.Tests/Adobe/Fixtures/adobe-advisories.snapshot.json @@ -1,574 +1,580 @@ -[ - { - "advisoryKey": "APSB25-85", - "affectedPackages": [ - { - "type": "vendor", - "identifier": "Acrobat DC", - "platform": "Windows", - "versionRanges": [ - { - "fixedVersion": "25.001.20680", - "introducedVersion": null, - "lastAffectedVersion": "25.001.20672", - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "exactValue": null, - "fixed": "25.1.20680", - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "25.1.20672", - "lastAffectedInclusive": true, - "style": "lessThan" - }, - "vendorExtensions": { - "adobe.track": "Continuous", - "adobe.platform": "Windows", - "adobe.affected.raw": "25.001.20672 and earlier", - "adobe.updated.raw": "25.001.20680", - "adobe.priority": "Priority 2", - "adobe.availability": "Available" - } - }, - "provenance": { - "source": "vndr-adobe", - "kind": "range", - "value": "Acrobat DC:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "25.001.20672 and earlier", - "rangeKind": "vendor" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "lt", - "min": null, - "minInclusive": null, - "max": "25.1.20680", - "maxInclusive": false, - "value": null, - "notes": "adobe:Acrobat DC:Windows" - } - ], - "statuses": [ - { - "provenance": { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat DC:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat DC:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ] - }, - { - "type": "vendor", - "identifier": "Acrobat DC", - "platform": "macOS", - "versionRanges": [ - { - "fixedVersion": "25.001.20678", - "introducedVersion": null, - "lastAffectedVersion": "25.001.20668", - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "exactValue": null, - "fixed": "25.1.20678", - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "25.1.20668", - "lastAffectedInclusive": true, - "style": "lessThan" - }, - "vendorExtensions": { - "adobe.track": "Continuous", - "adobe.platform": "macOS", - "adobe.affected.raw": "25.001.20668 and earlier", - "adobe.updated.raw": "25.001.20678", - "adobe.priority": "Priority 2", - "adobe.availability": "Available" - } - }, - "provenance": { - "source": "vndr-adobe", - "kind": "range", - "value": "Acrobat DC:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "25.001.20668 and earlier", - "rangeKind": "vendor" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "lt", - "min": null, - "minInclusive": null, - "max": "25.1.20678", - "maxInclusive": false, - "value": null, - "notes": "adobe:Acrobat DC:macOS" - } - ], - "statuses": [ - { - "provenance": { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat DC:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat DC:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ] - }, - { - "type": "vendor", - "identifier": "Acrobat Reader DC", - "platform": "Windows", - "versionRanges": [ - { - "fixedVersion": "25.001.20680", - "introducedVersion": null, - "lastAffectedVersion": "25.001.20672", - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "exactValue": null, - "fixed": "25.1.20680", - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "25.1.20672", - "lastAffectedInclusive": true, - "style": "lessThan" - }, - "vendorExtensions": { - "adobe.track": "Continuous", - "adobe.platform": "Windows", - "adobe.affected.raw": "25.001.20672 and earlier", - "adobe.updated.raw": "25.001.20680", - "adobe.priority": "Priority 2", - "adobe.availability": "Available" - } - }, - "provenance": { - "source": "vndr-adobe", - "kind": "range", - "value": "Acrobat Reader DC:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "25.001.20672 and earlier", - "rangeKind": "vendor" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "lt", - "min": null, - "minInclusive": null, - "max": "25.1.20680", - "maxInclusive": false, - "value": null, - "notes": "adobe:Acrobat Reader DC:Windows" - } - ], - "statuses": [ - { - "provenance": { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat Reader DC:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat Reader DC:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ] - }, - { - "type": "vendor", - "identifier": "Acrobat Reader DC", - "platform": "macOS", - "versionRanges": [ - { - "fixedVersion": "25.001.20678", - "introducedVersion": null, - "lastAffectedVersion": "25.001.20668", - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "exactValue": null, - "fixed": "25.1.20678", - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "25.1.20668", - "lastAffectedInclusive": true, - "style": "lessThan" - }, - "vendorExtensions": { - "adobe.track": "Continuous", - "adobe.platform": "macOS", - "adobe.affected.raw": "25.001.20668 and earlier", - "adobe.updated.raw": "25.001.20678", - "adobe.priority": "Priority 2", - "adobe.availability": "Available" - } - }, - "provenance": { - "source": "vndr-adobe", - "kind": "range", - "value": "Acrobat Reader DC:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "25.001.20668 and earlier", - "rangeKind": "vendor" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "lt", - "min": null, - "minInclusive": null, - "max": "25.1.20678", - "maxInclusive": false, - "value": null, - "notes": "adobe:Acrobat Reader DC:macOS" - } - ], - "statuses": [ - { - "provenance": { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat Reader DC:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "vndr-adobe", - "kind": "affected", - "value": "Acrobat Reader DC:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ] - } - ], - "aliases": [ - "APSB25-85" - ], - "credits": [], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": null, - "provenance": [ - { - "source": "vndr-adobe", - "kind": "parser", - "value": "APSB25-85", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ], - "published": "2025-09-09T00:00:00+00:00", - "references": [ - { - "kind": "advisory", - "provenance": { - "source": "vndr-adobe", - "kind": "parser", - "value": "APSB25-85", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": "adobe-psirt", - "summary": "Date published: September 9, 2025", - "url": "https://helpx.adobe.com/security/products/acrobat/apsb25-85.html" - } - ], - "severity": null, - "summary": "Date published: September 9, 2025", - "title": "APSB25-85: Security update available for Adobe Acrobat Reader" - }, - { - "advisoryKey": "APSB25-87", - "affectedPackages": [ - { - "type": "vendor", - "identifier": "Premiere Pro", - "platform": "Windows", - "versionRanges": [ - { - "fixedVersion": "24.6", - "introducedVersion": null, - "lastAffectedVersion": "24.5", - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "exactValue": null, - "fixed": "24.6", - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "24.5", - "lastAffectedInclusive": true, - "style": "lessThan" - }, - "vendorExtensions": { - "adobe.track": "Quarterly", - "adobe.platform": "Windows", - "adobe.affected.raw": "24.5 and earlier", - "adobe.updated.raw": "24.6", - "adobe.priority": "Priority 3", - "adobe.availability": "Available" - } - }, - "provenance": { - "source": "vndr-adobe", - "kind": "range", - "value": "Premiere Pro:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "24.5 and earlier", - "rangeKind": "vendor" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "lt", - "min": null, - "minInclusive": null, - "max": "24.6", - "maxInclusive": false, - "value": null, - "notes": "adobe:Premiere Pro:Windows" - } - ], - "statuses": [ - { - "provenance": { - "source": "vndr-adobe", - "kind": "affected", - "value": "Premiere Pro:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "vndr-adobe", - "kind": "affected", - "value": "Premiere Pro:Windows", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ] - }, - { - "type": "vendor", - "identifier": "Premiere Pro", - "platform": "macOS", - "versionRanges": [ - { - "fixedVersion": "24.6", - "introducedVersion": null, - "lastAffectedVersion": "24.5", - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "exactValue": null, - "fixed": "24.6", - "fixedInclusive": false, - "introduced": null, - "introducedInclusive": true, - "lastAffected": "24.5", - "lastAffectedInclusive": true, - "style": "lessThan" - }, - "vendorExtensions": { - "adobe.track": "Quarterly", - "adobe.platform": "macOS", - "adobe.affected.raw": "24.5 and earlier", - "adobe.updated.raw": "24.6", - "adobe.priority": "Priority 3", - "adobe.availability": "Available" - } - }, - "provenance": { - "source": "vndr-adobe", - "kind": "range", - "value": "Premiere Pro:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "rangeExpression": "24.5 and earlier", - "rangeKind": "vendor" - } - ], - "normalizedVersions": [ - { - "scheme": "semver", - "type": "lt", - "min": null, - "minInclusive": null, - "max": "24.6", - "maxInclusive": false, - "value": null, - "notes": "adobe:Premiere Pro:macOS" - } - ], - "statuses": [ - { - "provenance": { - "source": "vndr-adobe", - "kind": "affected", - "value": "Premiere Pro:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "status": "fixed" - } - ], - "provenance": [ - { - "source": "vndr-adobe", - "kind": "affected", - "value": "Premiere Pro:macOS", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ] - } - ], - "aliases": [ - "APSB25-87" - ], - "credits": [], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": null, - "provenance": [ - { - "source": "vndr-adobe", - "kind": "parser", - "value": "APSB25-87", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - } - ], - "published": "2025-09-08T00:00:00+00:00", - "references": [ - { - "kind": "advisory", - "provenance": { - "source": "vndr-adobe", - "kind": "parser", - "value": "APSB25-87", - "decisionReason": null, - "recordedAt": "2025-09-10T00:00:00+00:00", - "fieldMask": [] - }, - "sourceTag": "adobe-psirt", - "summary": "Date published: September 8, 2025", - "url": "https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html" - } - ], - "severity": null, - "summary": "Date published: September 8, 2025", - "title": "APSB25-87: Security update available for Adobe Premiere Pro" - } +[ + { + "advisoryKey": "APSB25-85", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "Acrobat DC", + "platform": "Windows", + "versionRanges": [ + { + "fixedVersion": "25.001.20680", + "introducedVersion": null, + "lastAffectedVersion": "25.001.20672", + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": "25.1.20680", + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "25.1.20672", + "lastAffectedInclusive": true, + "style": "lessThan" + }, + "vendorExtensions": { + "adobe.track": "Continuous", + "adobe.platform": "Windows", + "adobe.affected.raw": "25.001.20672 and earlier", + "adobe.updated.raw": "25.001.20680", + "adobe.priority": "Priority 2", + "adobe.availability": "Available" + } + }, + "provenance": { + "source": "vndr-adobe", + "kind": "range", + "value": "Acrobat DC:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "25.001.20672 and earlier", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "lt", + "min": null, + "minInclusive": null, + "max": "25.1.20680", + "maxInclusive": false, + "value": null, + "notes": "adobe:Acrobat DC:Windows" + } + ], + "statuses": [ + { + "provenance": { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat DC:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat DC:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "Acrobat DC", + "platform": "macOS", + "versionRanges": [ + { + "fixedVersion": "25.001.20678", + "introducedVersion": null, + "lastAffectedVersion": "25.001.20668", + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": "25.1.20678", + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "25.1.20668", + "lastAffectedInclusive": true, + "style": "lessThan" + }, + "vendorExtensions": { + "adobe.track": "Continuous", + "adobe.platform": "macOS", + "adobe.affected.raw": "25.001.20668 and earlier", + "adobe.updated.raw": "25.001.20678", + "adobe.priority": "Priority 2", + "adobe.availability": "Available" + } + }, + "provenance": { + "source": "vndr-adobe", + "kind": "range", + "value": "Acrobat DC:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "25.001.20668 and earlier", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "lt", + "min": null, + "minInclusive": null, + "max": "25.1.20678", + "maxInclusive": false, + "value": null, + "notes": "adobe:Acrobat DC:macOS" + } + ], + "statuses": [ + { + "provenance": { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat DC:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat DC:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "Acrobat Reader DC", + "platform": "Windows", + "versionRanges": [ + { + "fixedVersion": "25.001.20680", + "introducedVersion": null, + "lastAffectedVersion": "25.001.20672", + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": "25.1.20680", + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "25.1.20672", + "lastAffectedInclusive": true, + "style": "lessThan" + }, + "vendorExtensions": { + "adobe.track": "Continuous", + "adobe.platform": "Windows", + "adobe.affected.raw": "25.001.20672 and earlier", + "adobe.updated.raw": "25.001.20680", + "adobe.priority": "Priority 2", + "adobe.availability": "Available" + } + }, + "provenance": { + "source": "vndr-adobe", + "kind": "range", + "value": "Acrobat Reader DC:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "25.001.20672 and earlier", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "lt", + "min": null, + "minInclusive": null, + "max": "25.1.20680", + "maxInclusive": false, + "value": null, + "notes": "adobe:Acrobat Reader DC:Windows" + } + ], + "statuses": [ + { + "provenance": { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat Reader DC:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat Reader DC:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "Acrobat Reader DC", + "platform": "macOS", + "versionRanges": [ + { + "fixedVersion": "25.001.20678", + "introducedVersion": null, + "lastAffectedVersion": "25.001.20668", + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": "25.1.20678", + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "25.1.20668", + "lastAffectedInclusive": true, + "style": "lessThan" + }, + "vendorExtensions": { + "adobe.track": "Continuous", + "adobe.platform": "macOS", + "adobe.affected.raw": "25.001.20668 and earlier", + "adobe.updated.raw": "25.001.20678", + "adobe.priority": "Priority 2", + "adobe.availability": "Available" + } + }, + "provenance": { + "source": "vndr-adobe", + "kind": "range", + "value": "Acrobat Reader DC:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "25.001.20668 and earlier", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "lt", + "min": null, + "minInclusive": null, + "max": "25.1.20678", + "maxInclusive": false, + "value": null, + "notes": "adobe:Acrobat Reader DC:macOS" + } + ], + "statuses": [ + { + "provenance": { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat Reader DC:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "vndr-adobe", + "kind": "affected", + "value": "Acrobat Reader DC:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "APSB25-85" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "vndr-adobe", + "kind": "parser", + "value": "APSB25-85", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ], + "published": "2025-09-09T00:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "vndr-adobe", + "kind": "parser", + "value": "APSB25-85", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "adobe-psirt", + "summary": "Date published: September 9, 2025", + "url": "https://helpx.adobe.com/security/products/acrobat/apsb25-85.html" + } + ], + "severity": null, + "summary": "Date published: September 9, 2025", + "title": "APSB25-85: Security update available for Adobe Acrobat Reader" + }, + { + "advisoryKey": "APSB25-87", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "Premiere Pro", + "platform": "Windows", + "versionRanges": [ + { + "fixedVersion": "24.6", + "introducedVersion": null, + "lastAffectedVersion": "24.5", + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": "24.6", + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "24.5", + "lastAffectedInclusive": true, + "style": "lessThan" + }, + "vendorExtensions": { + "adobe.track": "Quarterly", + "adobe.platform": "Windows", + "adobe.affected.raw": "24.5 and earlier", + "adobe.updated.raw": "24.6", + "adobe.priority": "Priority 3", + "adobe.availability": "Available" + } + }, + "provenance": { + "source": "vndr-adobe", + "kind": "range", + "value": "Premiere Pro:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "24.5 and earlier", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "lt", + "min": null, + "minInclusive": null, + "max": "24.6", + "maxInclusive": false, + "value": null, + "notes": "adobe:Premiere Pro:Windows" + } + ], + "statuses": [ + { + "provenance": { + "source": "vndr-adobe", + "kind": "affected", + "value": "Premiere Pro:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "vndr-adobe", + "kind": "affected", + "value": "Premiere Pro:Windows", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "Premiere Pro", + "platform": "macOS", + "versionRanges": [ + { + "fixedVersion": "24.6", + "introducedVersion": null, + "lastAffectedVersion": "24.5", + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": "24.6", + "fixedInclusive": false, + "introduced": null, + "introducedInclusive": true, + "lastAffected": "24.5", + "lastAffectedInclusive": true, + "style": "lessThan" + }, + "vendorExtensions": { + "adobe.track": "Quarterly", + "adobe.platform": "macOS", + "adobe.affected.raw": "24.5 and earlier", + "adobe.updated.raw": "24.6", + "adobe.priority": "Priority 3", + "adobe.availability": "Available" + } + }, + "provenance": { + "source": "vndr-adobe", + "kind": "range", + "value": "Premiere Pro:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "24.5 and earlier", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [ + { + "scheme": "semver", + "type": "lt", + "min": null, + "minInclusive": null, + "max": "24.6", + "maxInclusive": false, + "value": null, + "notes": "adobe:Premiere Pro:macOS" + } + ], + "statuses": [ + { + "provenance": { + "source": "vndr-adobe", + "kind": "affected", + "value": "Premiere Pro:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "status": "fixed" + } + ], + "provenance": [ + { + "source": "vndr-adobe", + "kind": "affected", + "value": "Premiere Pro:macOS", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "APSB25-87" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "vndr-adobe", + "kind": "parser", + "value": "APSB25-87", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + } + ], + "published": "2025-09-08T00:00:00+00:00", + "references": [ + { + "kind": "advisory", + "provenance": { + "source": "vndr-adobe", + "kind": "parser", + "value": "APSB25-87", + "decisionReason": null, + "recordedAt": "2025-09-10T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "adobe-psirt", + "summary": "Date published: September 8, 2025", + "url": "https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html" + } + ], + "severity": null, + "summary": "Date published: September 8, 2025", + "title": "APSB25-87: Security update available for Adobe Premiere Pro" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Chromium.Tests/Chromium/Fixtures/chromium-advisory.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Chromium.Tests/Chromium/Fixtures/chromium-advisory.snapshot.json index 2a54569a8..03a3b5bd0 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Chromium.Tests/Chromium/Fixtures/chromium-advisory.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Chromium.Tests/Chromium/Fixtures/chromium-advisory.snapshot.json @@ -1 +1 @@ -{"advisoryKey":"chromium/post/stable-channel-update-for-desktop","affectedPackages":[{"identifier":"google:chrome","platform":"android","provenance":[{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"}],"statuses":[],"type":"vendor","versionRanges":[{"fixedVersion":"128.0.6613.89","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"stable","chromium.platform":"android","chromium.version.raw":"128.0.6613.89","chromium.version.normalized":"128.0.6613.89","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"89"}},"provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"rangeExpression":null,"rangeKind":"vendor"}]},{"identifier":"google:chrome","platform":"linux","provenance":[{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"}],"statuses":[],"type":"vendor","versionRanges":[{"fixedVersion":"128.0.6613.137","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"stable","chromium.platform":"linux","chromium.version.raw":"128.0.6613.137","chromium.version.normalized":"128.0.6613.137","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"137"}},"provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"rangeExpression":null,"rangeKind":"vendor"}]},{"identifier":"google:chrome","platform":"windows-mac","provenance":[{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"}],"statuses":[],"type":"vendor","versionRanges":[{"fixedVersion":"128.0.6613.138","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"stable","chromium.platform":"windows-mac","chromium.version.raw":"128.0.6613.138","chromium.version.normalized":"128.0.6613.138","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"138"}},"provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"rangeExpression":null,"rangeKind":"vendor"}]},{"identifier":"google:chrome:extended-stable","platform":"windows-mac","provenance":[{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"}],"statuses":[],"type":"vendor","versionRanges":[{"fixedVersion":"128.0.6613.138","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"extended-stable","chromium.platform":"windows-mac","chromium.version.raw":"128.0.6613.138","chromium.version.normalized":"128.0.6613.138","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"138"}},"provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"rangeExpression":null,"rangeKind":"vendor"}]}],"aliases":["CHROMIUM-POST:2024-09-10","CHROMIUM-POST:stable-channel-update-for-desktop","CVE-2024-12345","CVE-2024-22222"],"cvssMetrics":[],"exploitKnown":false,"language":"en","modified":"2024-09-10T17:45:00+00:00","provenance":[{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"}],"published":"2024-09-10T17:30:00+00:00","references":[{"kind":"advisory","provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"sourceTag":"chromium-blog","summary":null,"url":"https://chromereleases.googleblog.com/2024/09/stable-channel-update-for-desktop.html"},{"kind":"changelog","provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"sourceTag":"changelog","summary":"log","url":"https://chromium.googlesource.com/chromium/src/+log/128.0.6613.120..128.0.6613.138"},{"kind":"doc","provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"sourceTag":"doc","summary":"security page","url":"https://chromium.org/Home/chromium-security"},{"kind":"bug","provenance":{"fieldMask":[],"kind":"document","recordedAt":"2024-09-10T18:00:00+00:00","source":"vndr-chromium","value":"stable-channel-update-for-desktop"},"sourceTag":"bug","summary":"issue tracker","url":"https://issues.chromium.org/issues/123456789"}],"severity":null,"summary":"Stable channel update rolling out to Windows, macOS, Linux.","title":"Stable Channel Update for Desktop"} \ No newline at end of file +{"advisoryKey":"chromium/post/stable-channel-update-for-desktop","affectedPackages":[{"type":"vendor","identifier":"google:chrome","platform":"android","versionRanges":[{"fixedVersion":"128.0.6613.89","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"stable","chromium.platform":"android","chromium.version.raw":"128.0.6613.89","chromium.version.normalized":"128.0.6613.89","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"89"}},"provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"rangeExpression":null,"rangeKind":"vendor"}],"normalizedVersions":[],"statuses":[],"provenance":[{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]}]},{"type":"vendor","identifier":"google:chrome","platform":"linux","versionRanges":[{"fixedVersion":"128.0.6613.137","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"stable","chromium.platform":"linux","chromium.version.raw":"128.0.6613.137","chromium.version.normalized":"128.0.6613.137","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"137"}},"provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"rangeExpression":null,"rangeKind":"vendor"}],"normalizedVersions":[],"statuses":[],"provenance":[{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]}]},{"type":"vendor","identifier":"google:chrome","platform":"windows-mac","versionRanges":[{"fixedVersion":"128.0.6613.138","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"stable","chromium.platform":"windows-mac","chromium.version.raw":"128.0.6613.138","chromium.version.normalized":"128.0.6613.138","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"138"}},"provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"rangeExpression":null,"rangeKind":"vendor"}],"normalizedVersions":[],"statuses":[],"provenance":[{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]}]},{"type":"vendor","identifier":"google:chrome:extended-stable","platform":"windows-mac","versionRanges":[{"fixedVersion":"128.0.6613.138","introducedVersion":null,"lastAffectedVersion":null,"primitives":{"evr":null,"hasVendorExtensions":true,"nevra":null,"semVer":null,"vendorExtensions":{"chromium.channel":"extended-stable","chromium.platform":"windows-mac","chromium.version.raw":"128.0.6613.138","chromium.version.normalized":"128.0.6613.138","chromium.version.major":"128","chromium.version.minor":"0","chromium.version.build":"6613","chromium.version.patch":"138"}},"provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"rangeExpression":null,"rangeKind":"vendor"}],"normalizedVersions":[],"statuses":[],"provenance":[{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]}]}],"aliases":["CHROMIUM-POST:2024-09-10","CHROMIUM-POST:stable-channel-update-for-desktop","CVE-2024-12345","CVE-2024-22222"],"canonicalMetricId":null,"credits":[],"cvssMetrics":[],"cwes":[],"description":null,"exploitKnown":false,"language":"en","modified":"2024-09-10T17:45:00+00:00","provenance":[{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]}],"published":"2024-09-10T17:30:00+00:00","references":[{"kind":"advisory","provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"sourceTag":"chromium-blog","summary":null,"url":"https://chromereleases.googleblog.com/2024/09/stable-channel-update-for-desktop.html"},{"kind":"changelog","provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"sourceTag":"changelog","summary":"log","url":"https://chromium.googlesource.com/chromium/src/+log/128.0.6613.120..128.0.6613.138"},{"kind":"doc","provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"sourceTag":"doc","summary":"security page","url":"https://chromium.org/Home/chromium-security"},{"kind":"bug","provenance":{"source":"vndr-chromium","kind":"document","value":"stable-channel-update-for-desktop","decisionReason":null,"recordedAt":"2024-09-10T18:00:00+00:00","fieldMask":[]},"sourceTag":"bug","summary":"issue tracker","url":"https://issues.chromium.org/issues/123456789"}],"severity":null,"summary":"Stable channel update rolling out to Windows, macOS, Linux.","title":"Stable Channel Update for Desktop"} \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Cisco.Tests/CiscoMapperTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Cisco.Tests/CiscoMapperTests.cs index e8eea6cd9..d9d347f39 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Cisco.Tests/CiscoMapperTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Cisco.Tests/CiscoMapperTests.cs @@ -1,5 +1,6 @@ using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Linq; using FluentAssertions; using MongoDB.Bson; using StellaOps.Concelier.Models; @@ -20,23 +21,24 @@ public sealed class CiscoMapperTests var published = new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero); var updated = published.AddDays(1); - var dto = new CiscoAdvisoryDto( - AdvisoryId: "CISCO-SA-TEST", - Title: "Test Advisory", - Summary: "Sample summary", - Severity: "High", + var dto = new CiscoAdvisoryDto( + AdvisoryId: "CISCO-SA-TEST", + Title: "Test Advisory", + Summary: "Sample summary", + Severity: "High", Published: published, Updated: updated, PublicationUrl: "https://example.com/advisory", CsafUrl: "https://sec.cloudapps.cisco.com/csaf/test.json", CvrfUrl: "https://example.com/cvrf.xml", - CvssBaseScore: 9.8, - Cves: new List { "CVE-2024-0001" }, - BugIds: new List { "BUG123" }, - Products: new List - { - new("Cisco Widget", "PID-1", "1.2.3", new [] { AffectedPackageStatusCatalog.KnownAffected }) - }); + CvssBaseScore: 9.8, + Cves: new List { "CVE-2024-0001" }, + BugIds: new List { "BUG123" }, + Products: new List + { + new("Cisco Widget", "PID-1", "1.2.3", new [] { AffectedPackageStatusCatalog.KnownAffected }), + new("Cisco Router", "PID-2", ">=1.0.0 <1.4.0", new [] { AffectedPackageStatusCatalog.KnownAffected }) + }); var document = new DocumentRecord( Id: Guid.NewGuid(), @@ -62,18 +64,38 @@ public sealed class CiscoMapperTests advisory.Aliases.Should().Contain(new[] { "CISCO-SA-TEST", "CVE-2024-0001", "BUG123" }); advisory.References.Should().Contain(reference => reference.Url == "https://example.com/advisory"); advisory.References.Should().Contain(reference => reference.Url == "https://sec.cloudapps.cisco.com/csaf/test.json"); - advisory.AffectedPackages.Should().HaveCount(1); - - var package = advisory.AffectedPackages[0]; - package.Type.Should().Be(AffectedPackageTypes.Vendor); - package.Identifier.Should().Be("Cisco Widget"); - package.Statuses.Should().ContainSingle(status => status.Status == AffectedPackageStatusCatalog.KnownAffected); - package.VersionRanges.Should().ContainSingle(); - var range = package.VersionRanges[0]; - range.RangeKind.Should().Be("semver"); - range.Provenance.Source.Should().Be(VndrCiscoConnectorPlugin.SourceName); - range.Primitives.Should().NotBeNull(); - range.Primitives!.SemVer.Should().NotBeNull(); - range.Primitives.SemVer!.ExactValue.Should().Be("1.2.3"); - } -} + advisory.AffectedPackages.Should().HaveCount(2); + + var package = advisory.AffectedPackages.Single(p => p.Identifier == "Cisco Widget"); + package.Type.Should().Be(AffectedPackageTypes.Vendor); + package.Identifier.Should().Be("Cisco Widget"); + package.Statuses.Should().ContainSingle(status => status.Status == AffectedPackageStatusCatalog.KnownAffected); + package.VersionRanges.Should().ContainSingle(); + var range = package.VersionRanges[0]; + range.RangeKind.Should().Be("semver"); + range.Provenance.Source.Should().Be(VndrCiscoConnectorPlugin.SourceName); + range.Primitives.Should().NotBeNull(); + range.Primitives!.SemVer.Should().NotBeNull(); + range.Primitives.SemVer!.ExactValue.Should().Be("1.2.3"); + + package.NormalizedVersions.Should().ContainSingle(); + var normalized = package.NormalizedVersions[0]; + normalized.Scheme.Should().Be(NormalizedVersionSchemes.SemVer); + normalized.Type.Should().Be(NormalizedVersionRuleTypes.Exact); + normalized.Value.Should().Be("1.2.3"); + normalized.Notes.Should().Be("cisco:pid-1"); + + var rangePackage = advisory.AffectedPackages.Single(p => p.Identifier == "Cisco Router"); + rangePackage.VersionRanges.Should().ContainSingle(); + var rangePackageRange = rangePackage.VersionRanges[0]; + rangePackageRange.Primitives!.SemVer.Should().NotBeNull(); + rangePackageRange.Primitives.SemVer!.Introduced.Should().Be("1.0.0"); + rangePackageRange.Primitives.SemVer.Fixed.Should().Be("1.4.0"); + rangePackage.NormalizedVersions.Should().ContainSingle(rule => + rule.Min == "1.0.0" && + rule.Max == "1.4.0" && + rule.MinInclusive == true && + rule.MaxInclusive == false && + rule.Notes == "cisco:pid-2"); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Oracle.Tests/Oracle/Fixtures/oracle-advisories.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Oracle.Tests/Oracle/Fixtures/oracle-advisories.snapshot.json index 7c2643939..f0285fd8c 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Oracle.Tests/Oracle/Fixtures/oracle-advisories.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Oracle.Tests/Oracle/Fixtures/oracle-advisories.snapshot.json @@ -1,495 +1,535 @@ -[ - { - "advisoryKey": "oracle/cpuapr2024-01-html", - "affectedPackages": [ - { - "identifier": "Oracle GraalVM for JDK::Libraries", - "platform": "Libraries", - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle GraalVM for JDK::Libraries" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "oracle.product": "Oracle GraalVM for JDK", - "oracle.productRaw": "Oracle Java SE, Oracle GraalVM for JDK", - "oracle.component": "Libraries", - "oracle.componentRaw": "Libraries", - "oracle.segmentVersions": "21.3.8, 22.0.0", - "oracle.supportedVersions": "Oracle Java SE: 8u401, 11.0.22; Oracle GraalVM for JDK: 21.3.8, 22.0.0", - "oracle.rangeExpression": "21.3.8, 22.0.0 (notes: See Note A for mitigation)", - "oracle.baseExpression": "21.3.8, 22.0.0", - "oracle.notes": "See Note A for mitigation", - "oracle.versionTokens": "21.3.8|22.0.0", - "oracle.versionTokens.normalized": "21.3.8|22.0.0" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle GraalVM for JDK::Libraries" - }, - "rangeExpression": "21.3.8, 22.0.0 (notes: See Note A for mitigation)", - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "Oracle Java SE::Hotspot", - "platform": "Hotspot", - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle Java SE::Hotspot" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": "8u401", - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "oracle.product": "Oracle Java SE", - "oracle.productRaw": "Oracle Java SE", - "oracle.component": "Hotspot", - "oracle.componentRaw": "Hotspot", - "oracle.segmentVersions": "Oracle Java SE: 8u401, 11.0.22", - "oracle.supportedVersions": "Oracle Java SE: 8u401, 11.0.22", - "oracle.rangeExpression": "Oracle Java SE: 8u401, 11.0.22 (notes: Fixed in 8u401 Patch 123456)", - "oracle.baseExpression": "Oracle Java SE: 8u401, 11.0.22", - "oracle.notes": "Fixed in 8u401 Patch 123456", - "oracle.fixedVersion": "8u401", - "oracle.patchNumber": "123456", - "oracle.versionTokens": "Oracle Java SE: 8u401|11.0.22", - "oracle.versionTokens.normalized": "11.0.22" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle Java SE::Hotspot" - }, - "rangeExpression": "Oracle Java SE: 8u401, 11.0.22 (notes: Fixed in 8u401 Patch 123456)", - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "Oracle Java SE::Libraries", - "platform": "Libraries", - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle Java SE::Libraries" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "oracle.product": "Oracle Java SE", - "oracle.productRaw": "Oracle Java SE, Oracle GraalVM for JDK", - "oracle.component": "Libraries", - "oracle.componentRaw": "Libraries", - "oracle.segmentVersions": "8u401, 11.0.22", - "oracle.supportedVersions": "Oracle Java SE: 8u401, 11.0.22; Oracle GraalVM for JDK: 21.3.8, 22.0.0", - "oracle.rangeExpression": "8u401, 11.0.22 (notes: See Note A for mitigation)", - "oracle.baseExpression": "8u401, 11.0.22", - "oracle.notes": "See Note A for mitigation", - "oracle.versionTokens": "8u401|11.0.22", - "oracle.versionTokens.normalized": "11.0.22" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle Java SE::Libraries" - }, - "rangeExpression": "8u401, 11.0.22 (notes: See Note A for mitigation)", - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CVE-2024-9000", - "CVE-2024-9001", - "ORACLE:CPUAPR2024-01-HTML" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": null, - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-04-18T00:00:00+00:00", - "source": "vndr-oracle", - "value": "https://www.oracle.com/security-alerts/cpuapr2024-01.html" - }, - { - "fieldMask": [], - "kind": "mapping", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "cpuapr2024-01-html" - } - ], - "published": "2024-04-18T12:30:00+00:00", - "references": [ - { - "kind": "reference", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://support.oracle.com/kb/123456" - }, - "sourceTag": null, - "summary": null, - "url": "https://support.oracle.com/kb/123456" - }, - { - "kind": "patch", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://support.oracle.com/rs?type=doc&id=3010001.1" - }, - "sourceTag": "oracle", - "summary": "Oracle Java SE", - "url": "https://support.oracle.com/rs?type=doc&id=3010001.1" - }, - { - "kind": "patch", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://support.oracle.com/rs?type=doc&id=3010002.1" - }, - "sourceTag": "oracle", - "summary": "Oracle GraalVM", - "url": "https://support.oracle.com/rs?type=doc&id=3010002.1" - }, - { - "kind": "reference", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://updates.oracle.com/patches/fullpatch" - }, - "sourceTag": null, - "summary": null, - "url": "https://updates.oracle.com/patches/fullpatch" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-9000" - }, - "sourceTag": "CVE-2024-9000", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-9000" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-9001" - }, - "sourceTag": "CVE-2024-9001", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-9001" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://www.oracle.com/security-alerts/cpuapr2024-01.html" - }, - "sourceTag": "oracle", - "summary": "cpuapr2024 01 html", - "url": "https://www.oracle.com/security-alerts/cpuapr2024-01.html" - } - ], - "severity": null, - "summary": "Oracle CPU April 2024 Advisory 1 Oracle Critical Patch Update Advisory - April 2024 (CPU01) This advisory addresses vulnerabilities in Oracle Java SE and Oracle GraalVM for JDK. It references CVE-2024-9000 and CVE-2024-9001 with additional remediation steps. Affected Products and Versions Patch Availability Document Oracle Java SE, versions 8u401, 11.0.22 Oracle Java SE Oracle GraalVM for JDK, versions 21.3.8, 22.0.0 Oracle GraalVM CVE ID Product Component Protocol Remote Exploit without Auth.? Base Score Attack Vector Attack Complex Privs Req'd User Interact Scope Confidentiality Integrity Availability Supported Versions Affected Notes CVE-2024-9000 Oracle Java SE Hotspot Multiple Yes 9.8 Network Low None Required Changed High High High Oracle Java SE: 8u401, 11.0.22 Fixed in 8u401 Patch 123456 CVE-2024-9001 Oracle Java SE, Oracle GraalVM for JDK Libraries Multiple Yes 7.5 Network High None Required Changed Medium Medium Medium Oracle Java SE: 8u401, 11.0.22; Oracle GraalVM for JDK: 21.3.8, 22.0.0 See Note A for mitigation Note A: Apply interim update 22.0.0.1 for GraalVM. Patch download Support article", - "title": "cpuapr2024 01 html" - }, - { - "advisoryKey": "oracle/cpuapr2024-02-html", - "affectedPackages": [ - { - "identifier": "Oracle Database Server::SQL*Plus", - "platform": "SQL*Plus", - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle Database Server::SQL*Plus" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": null, - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "oracle.product": "Oracle Database Server", - "oracle.productRaw": "Oracle Database Server", - "oracle.component": "SQL*Plus", - "oracle.componentRaw": "SQL*Plus", - "oracle.segmentVersions": "Oracle Database Server: 19c, 21c", - "oracle.supportedVersions": "Oracle Database Server: 19c, 21c", - "oracle.rangeExpression": "Oracle Database Server: 19c, 21c (notes: See Note B)", - "oracle.baseExpression": "Oracle Database Server: 19c, 21c", - "oracle.notes": "See Note B", - "oracle.versionTokens": "Oracle Database Server: 19c|21c" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle Database Server::SQL*Plus" - }, - "rangeExpression": "Oracle Database Server: 19c, 21c (notes: See Note B)", - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "Oracle WebLogic Server::Console", - "platform": "Console", - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle WebLogic Server::Console" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": "99999999", - "introducedVersion": null, - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": null, - "vendorExtensions": { - "oracle.product": "Oracle WebLogic Server", - "oracle.productRaw": "Oracle WebLogic Server", - "oracle.component": "Console", - "oracle.componentRaw": "Console", - "oracle.segmentVersions": "Oracle WebLogic Server: 14.1.1.0.0", - "oracle.supportedVersions": "Oracle WebLogic Server: 14.1.1.0.0", - "oracle.rangeExpression": "Oracle WebLogic Server: 14.1.1.0.0 (notes: Patch 99999999 available)", - "oracle.baseExpression": "Oracle WebLogic Server: 14.1.1.0.0", - "oracle.notes": "Patch 99999999 available", - "oracle.fixedVersion": "99999999", - "oracle.patchNumber": "99999999", - "oracle.versionTokens": "Oracle WebLogic Server: 14.1.1.0.0" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "Oracle WebLogic Server::Console" - }, - "rangeExpression": "Oracle WebLogic Server: 14.1.1.0.0 (notes: Patch 99999999 available)", - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CVE-2024-9100", - "CVE-2024-9101", - "ORACLE:CPUAPR2024-02-HTML" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": null, - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-04-18T00:00:00+00:00", - "source": "vndr-oracle", - "value": "https://www.oracle.com/security-alerts/cpuapr2024-02.html" - }, - { - "fieldMask": [], - "kind": "mapping", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "cpuapr2024-02-html" - } - ], - "published": "2024-04-19T08:15:00+00:00", - "references": [ - { - "kind": "reference", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://support.oracle.com/kb/789012" - }, - "sourceTag": null, - "summary": null, - "url": "https://support.oracle.com/kb/789012" - }, - { - "kind": "patch", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://support.oracle.com/rs?type=doc&id=3010100.1" - }, - "sourceTag": "oracle", - "summary": "Fusion Middleware", - "url": "https://support.oracle.com/rs?type=doc&id=3010100.1" - }, - { - "kind": "patch", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://support.oracle.com/rs?type=doc&id=3010101.1" - }, - "sourceTag": "oracle", - "summary": "Database", - "url": "https://support.oracle.com/rs?type=doc&id=3010101.1" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-9100" - }, - "sourceTag": "CVE-2024-9100", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-9100" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://www.cve.org/CVERecord?id=CVE-2024-9101" - }, - "sourceTag": "CVE-2024-9101", - "summary": null, - "url": "https://www.cve.org/CVERecord?id=CVE-2024-9101" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-18T00:01:00+00:00", - "source": "vndr-oracle", - "value": "https://www.oracle.com/security-alerts/cpuapr2024-02.html" - }, - "sourceTag": "oracle", - "summary": "cpuapr2024 02 html", - "url": "https://www.oracle.com/security-alerts/cpuapr2024-02.html" - } - ], - "severity": null, - "summary": "Oracle CPU April 2024 Advisory 2 Oracle Security Alert Advisory - April 2024 (CPU02) Mitigations for Oracle WebLogic Server and Oracle Database Server. Includes references to CVE-2024-9100 with additional product components. Affected Products and Versions Patch Availability Document Oracle WebLogic Server, versions 14.1.1.0.0 Fusion Middleware Oracle Database Server, versions 19c, 21c Database CVE ID Product Component Protocol Remote Exploit without Auth.? Base Score Attack Vector Attack Complex Privs Req'd User Interact Scope Confidentiality Integrity Availability Supported Versions Affected Notes CVE-2024-9100 Oracle WebLogic Server Console HTTP Yes 8.1 Network Low Low Required Changed High High High Oracle WebLogic Server: 14.1.1.0.0 Patch 99999999 available CVE-2024-9101 Oracle Database Server SQL*Plus Multiple No 5.4 Local Low Low None Unchanged Medium Low Low Oracle Database Server: 19c, 21c See Note B Note B: Customers should review Support Doc 3010101.1 for mitigation guidance. More details at Support KB .", - "title": "cpuapr2024 02 html" - } +[ + { + "advisoryKey": "oracle/cpuapr2024-01-html", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "Oracle GraalVM for JDK::Libraries", + "platform": "Libraries", + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "oracle.product": "Oracle GraalVM for JDK", + "oracle.productRaw": "Oracle Java SE, Oracle GraalVM for JDK", + "oracle.component": "Libraries", + "oracle.componentRaw": "Libraries", + "oracle.segmentVersions": "21.3.8, 22.0.0", + "oracle.supportedVersions": "Oracle Java SE: 8u401, 11.0.22; Oracle GraalVM for JDK: 21.3.8, 22.0.0", + "oracle.rangeExpression": "21.3.8, 22.0.0 (notes: See Note A for mitigation)", + "oracle.baseExpression": "21.3.8, 22.0.0", + "oracle.notes": "See Note A for mitigation", + "oracle.versionTokens": "21.3.8|22.0.0", + "oracle.versionTokens.normalized": "21.3.8|22.0.0" + } + }, + "provenance": { + "source": "vndr-oracle", + "kind": "range", + "value": "Oracle GraalVM for JDK::Libraries", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "21.3.8, 22.0.0 (notes: See Note A for mitigation)", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vndr-oracle", + "kind": "affected", + "value": "Oracle GraalVM for JDK::Libraries", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "Oracle Java SE::Hotspot", + "platform": "Hotspot", + "versionRanges": [ + { + "fixedVersion": "8u401", + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "oracle.product": "Oracle Java SE", + "oracle.productRaw": "Oracle Java SE", + "oracle.component": "Hotspot", + "oracle.componentRaw": "Hotspot", + "oracle.segmentVersions": "Oracle Java SE: 8u401, 11.0.22", + "oracle.supportedVersions": "Oracle Java SE: 8u401, 11.0.22", + "oracle.rangeExpression": "Oracle Java SE: 8u401, 11.0.22 (notes: Fixed in 8u401 Patch 123456)", + "oracle.baseExpression": "Oracle Java SE: 8u401, 11.0.22", + "oracle.notes": "Fixed in 8u401 Patch 123456", + "oracle.fixedVersion": "8u401", + "oracle.patchNumber": "123456", + "oracle.versionTokens": "Oracle Java SE: 8u401|11.0.22", + "oracle.versionTokens.normalized": "11.0.22" + } + }, + "provenance": { + "source": "vndr-oracle", + "kind": "range", + "value": "Oracle Java SE::Hotspot", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "Oracle Java SE: 8u401, 11.0.22 (notes: Fixed in 8u401 Patch 123456)", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vndr-oracle", + "kind": "affected", + "value": "Oracle Java SE::Hotspot", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "Oracle Java SE::Libraries", + "platform": "Libraries", + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "oracle.product": "Oracle Java SE", + "oracle.productRaw": "Oracle Java SE, Oracle GraalVM for JDK", + "oracle.component": "Libraries", + "oracle.componentRaw": "Libraries", + "oracle.segmentVersions": "8u401, 11.0.22", + "oracle.supportedVersions": "Oracle Java SE: 8u401, 11.0.22; Oracle GraalVM for JDK: 21.3.8, 22.0.0", + "oracle.rangeExpression": "8u401, 11.0.22 (notes: See Note A for mitigation)", + "oracle.baseExpression": "8u401, 11.0.22", + "oracle.notes": "See Note A for mitigation", + "oracle.versionTokens": "8u401|11.0.22", + "oracle.versionTokens.normalized": "11.0.22" + } + }, + "provenance": { + "source": "vndr-oracle", + "kind": "range", + "value": "Oracle Java SE::Libraries", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "8u401, 11.0.22 (notes: See Note A for mitigation)", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vndr-oracle", + "kind": "affected", + "value": "Oracle Java SE::Libraries", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CVE-2024-9000", + "CVE-2024-9001", + "ORACLE:CPUAPR2024-01-HTML" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "vndr-oracle", + "kind": "document", + "value": "https://www.oracle.com/security-alerts/cpuapr2024-01.html", + "decisionReason": null, + "recordedAt": "2024-04-18T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "vndr-oracle", + "kind": "mapping", + "value": "cpuapr2024-01-html", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-04-18T12:30:00+00:00", + "references": [ + { + "kind": "reference", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://support.oracle.com/kb/123456", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://support.oracle.com/kb/123456" + }, + { + "kind": "patch", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://support.oracle.com/rs?type=doc&id=3010001.1", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "oracle", + "summary": "Oracle Java SE", + "url": "https://support.oracle.com/rs?type=doc&id=3010001.1" + }, + { + "kind": "patch", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://support.oracle.com/rs?type=doc&id=3010002.1", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "oracle", + "summary": "Oracle GraalVM", + "url": "https://support.oracle.com/rs?type=doc&id=3010002.1" + }, + { + "kind": "reference", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://updates.oracle.com/patches/fullpatch", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://updates.oracle.com/patches/fullpatch" + }, + { + "kind": "advisory", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9000", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9000", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9000" + }, + { + "kind": "advisory", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9001", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9001", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9001" + }, + { + "kind": "advisory", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://www.oracle.com/security-alerts/cpuapr2024-01.html", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "oracle", + "summary": "cpuapr2024 01 html", + "url": "https://www.oracle.com/security-alerts/cpuapr2024-01.html" + } + ], + "severity": null, + "summary": "Oracle CPU April 2024 Advisory 1 Oracle Critical Patch Update Advisory - April 2024 (CPU01) This advisory addresses vulnerabilities in Oracle Java SE and Oracle GraalVM for JDK. It references CVE-2024-9000 and CVE-2024-9001 with additional remediation steps. Affected Products and Versions Patch Availability Document Oracle Java SE, versions 8u401, 11.0.22 Oracle Java SE Oracle GraalVM for JDK, versions 21.3.8, 22.0.0 Oracle GraalVM CVE ID Product Component Protocol Remote Exploit without Auth.? Base Score Attack Vector Attack Complex Privs Req'd User Interact Scope Confidentiality Integrity Availability Supported Versions Affected Notes CVE-2024-9000 Oracle Java SE Hotspot Multiple Yes 9.8 Network Low None Required Changed High High High Oracle Java SE: 8u401, 11.0.22 Fixed in 8u401 Patch 123456 CVE-2024-9001 Oracle Java SE, Oracle GraalVM for JDK Libraries Multiple Yes 7.5 Network High None Required Changed Medium Medium Medium Oracle Java SE: 8u401, 11.0.22; Oracle GraalVM for JDK: 21.3.8, 22.0.0 See Note A for mitigation Note A: Apply interim update 22.0.0.1 for GraalVM. Patch download Support article", + "title": "cpuapr2024 01 html" + }, + { + "advisoryKey": "oracle/cpuapr2024-02-html", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "Oracle Database Server::SQL*Plus", + "platform": "SQL*Plus", + "versionRanges": [ + { + "fixedVersion": null, + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "oracle.product": "Oracle Database Server", + "oracle.productRaw": "Oracle Database Server", + "oracle.component": "SQL*Plus", + "oracle.componentRaw": "SQL*Plus", + "oracle.segmentVersions": "Oracle Database Server: 19c, 21c", + "oracle.supportedVersions": "Oracle Database Server: 19c, 21c", + "oracle.rangeExpression": "Oracle Database Server: 19c, 21c (notes: See Note B)", + "oracle.baseExpression": "Oracle Database Server: 19c, 21c", + "oracle.notes": "See Note B", + "oracle.versionTokens": "Oracle Database Server: 19c|21c" + } + }, + "provenance": { + "source": "vndr-oracle", + "kind": "range", + "value": "Oracle Database Server::SQL*Plus", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "Oracle Database Server: 19c, 21c (notes: See Note B)", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vndr-oracle", + "kind": "affected", + "value": "Oracle Database Server::SQL*Plus", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "Oracle WebLogic Server::Console", + "platform": "Console", + "versionRanges": [ + { + "fixedVersion": "99999999", + "introducedVersion": null, + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": null, + "vendorExtensions": { + "oracle.product": "Oracle WebLogic Server", + "oracle.productRaw": "Oracle WebLogic Server", + "oracle.component": "Console", + "oracle.componentRaw": "Console", + "oracle.segmentVersions": "Oracle WebLogic Server: 14.1.1.0.0", + "oracle.supportedVersions": "Oracle WebLogic Server: 14.1.1.0.0", + "oracle.rangeExpression": "Oracle WebLogic Server: 14.1.1.0.0 (notes: Patch 99999999 available)", + "oracle.baseExpression": "Oracle WebLogic Server: 14.1.1.0.0", + "oracle.notes": "Patch 99999999 available", + "oracle.fixedVersion": "99999999", + "oracle.patchNumber": "99999999", + "oracle.versionTokens": "Oracle WebLogic Server: 14.1.1.0.0" + } + }, + "provenance": { + "source": "vndr-oracle", + "kind": "range", + "value": "Oracle WebLogic Server::Console", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "Oracle WebLogic Server: 14.1.1.0.0 (notes: Patch 99999999 available)", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vndr-oracle", + "kind": "affected", + "value": "Oracle WebLogic Server::Console", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CVE-2024-9100", + "CVE-2024-9101", + "ORACLE:CPUAPR2024-02-HTML" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": null, + "provenance": [ + { + "source": "vndr-oracle", + "kind": "document", + "value": "https://www.oracle.com/security-alerts/cpuapr2024-02.html", + "decisionReason": null, + "recordedAt": "2024-04-18T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "vndr-oracle", + "kind": "mapping", + "value": "cpuapr2024-02-html", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-04-19T08:15:00+00:00", + "references": [ + { + "kind": "reference", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://support.oracle.com/kb/789012", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": null, + "summary": null, + "url": "https://support.oracle.com/kb/789012" + }, + { + "kind": "patch", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://support.oracle.com/rs?type=doc&id=3010100.1", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "oracle", + "summary": "Fusion Middleware", + "url": "https://support.oracle.com/rs?type=doc&id=3010100.1" + }, + { + "kind": "patch", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://support.oracle.com/rs?type=doc&id=3010101.1", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "oracle", + "summary": "Database", + "url": "https://support.oracle.com/rs?type=doc&id=3010101.1" + }, + { + "kind": "advisory", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9100", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9100", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9100" + }, + { + "kind": "advisory", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://www.cve.org/CVERecord?id=CVE-2024-9101", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "CVE-2024-9101", + "summary": null, + "url": "https://www.cve.org/CVERecord?id=CVE-2024-9101" + }, + { + "kind": "advisory", + "provenance": { + "source": "vndr-oracle", + "kind": "reference", + "value": "https://www.oracle.com/security-alerts/cpuapr2024-02.html", + "decisionReason": null, + "recordedAt": "2024-04-18T00:01:00+00:00", + "fieldMask": [] + }, + "sourceTag": "oracle", + "summary": "cpuapr2024 02 html", + "url": "https://www.oracle.com/security-alerts/cpuapr2024-02.html" + } + ], + "severity": null, + "summary": "Oracle CPU April 2024 Advisory 2 Oracle Security Alert Advisory - April 2024 (CPU02) Mitigations for Oracle WebLogic Server and Oracle Database Server. Includes references to CVE-2024-9100 with additional product components. Affected Products and Versions Patch Availability Document Oracle WebLogic Server, versions 14.1.1.0.0 Fusion Middleware Oracle Database Server, versions 19c, 21c Database CVE ID Product Component Protocol Remote Exploit without Auth.? Base Score Attack Vector Attack Complex Privs Req'd User Interact Scope Confidentiality Integrity Availability Supported Versions Affected Notes CVE-2024-9100 Oracle WebLogic Server Console HTTP Yes 8.1 Network Low Low Required Changed High High High Oracle WebLogic Server: 14.1.1.0.0 Patch 99999999 available CVE-2024-9101 Oracle Database Server SQL*Plus Multiple No 5.4 Local Low Low None Unchanged Medium Low Low Oracle Database Server: 19c, 21c See Note B Note B: Customers should review Support Doc 3010101.1 for mitigation guidance. More details at Support KB .", + "title": "cpuapr2024 02 html" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Vmware.Tests/Vmware/Fixtures/vmware-advisories.snapshot.json b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Vmware.Tests/Vmware/Fixtures/vmware-advisories.snapshot.json index c10778edb..12147a639 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Vmware.Tests/Vmware/Fixtures/vmware-advisories.snapshot.json +++ b/src/Concelier/__Tests/StellaOps.Concelier.Connector.Vndr.Vmware.Tests/Vmware/Fixtures/vmware-advisories.snapshot.json @@ -1,275 +1,306 @@ -[ - { - "advisoryKey": "VMSA-2024-0001", - "affectedPackages": [ - { - "identifier": "VMware ESXi 7.0", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMware ESXi 7.0" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": "7.0u3f", - "introducedVersion": "7.0", - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "fixed": null, - "fixedInclusive": false, - "introduced": "7.0", - "introducedInclusive": true, - "lastAffected": null, - "lastAffectedInclusive": false - }, - "vendorExtensions": { - "vmware.product": "VMware ESXi 7.0", - "vmware.version.raw": "7.0", - "vmware.fixedVersion.raw": "7.0u3f" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMware ESXi 7.0" - }, - "rangeExpression": "7.0", - "rangeKind": "vendor" - } - ] - }, - { - "identifier": "VMware vCenter Server 8.0", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMware vCenter Server 8.0" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": "8.0a", - "introducedVersion": "8.0", - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "fixed": null, - "fixedInclusive": false, - "introduced": "8.0", - "introducedInclusive": true, - "lastAffected": null, - "lastAffectedInclusive": false - }, - "vendorExtensions": { - "vmware.product": "VMware vCenter Server 8.0", - "vmware.version.raw": "8.0", - "vmware.fixedVersion.raw": "8.0a" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMware vCenter Server 8.0" - }, - "rangeExpression": "8.0", - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CVE-2024-1000", - "CVE-2024-1001", - "VMSA-2024-0001" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": "2024-04-01T10:00:00+00:00", - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "https://vmware.example/api/vmsa/VMSA-2024-0001.json" - }, - { - "fieldMask": [], - "kind": "mapping", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMSA-2024-0001" - } - ], - "published": "2024-04-01T10:00:00+00:00", - "references": [ - { - "kind": "kb", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "https://kb.vmware.example/90234" - }, - "sourceTag": "kb", - "summary": null, - "url": "https://kb.vmware.example/90234" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "https://www.vmware.com/security/advisories/VMSA-2024-0001.html" - }, - "sourceTag": "advisory", - "summary": null, - "url": "https://www.vmware.com/security/advisories/VMSA-2024-0001.html" - } - ], - "severity": null, - "summary": "Security updates for VMware ESXi 7.0 and vCenter Server 8.0 resolve multiple vulnerabilities.", - "title": "VMware ESXi and vCenter Server updates address vulnerabilities" - }, - { - "advisoryKey": "VMSA-2024-0002", - "affectedPackages": [ - { - "identifier": "VMware Cloud Foundation 5.x", - "platform": null, - "provenance": [ - { - "fieldMask": [], - "kind": "affected", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMware Cloud Foundation 5.x" - } - ], - "statuses": [], - "type": "vendor", - "versionRanges": [ - { - "fixedVersion": "5.1.1", - "introducedVersion": "5.1", - "lastAffectedVersion": null, - "primitives": { - "evr": null, - "hasVendorExtensions": true, - "nevra": null, - "semVer": { - "constraintExpression": null, - "fixed": "5.1.1", - "fixedInclusive": false, - "introduced": "5.1", - "introducedInclusive": true, - "lastAffected": null, - "lastAffectedInclusive": false - }, - "vendorExtensions": { - "vmware.product": "VMware Cloud Foundation 5.x", - "vmware.version.raw": "5.1", - "vmware.fixedVersion.raw": "5.1.1" - } - }, - "provenance": { - "fieldMask": [], - "kind": "range", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMware Cloud Foundation 5.x" - }, - "rangeExpression": "5.1", - "rangeKind": "vendor" - } - ] - } - ], - "aliases": [ - "CVE-2024-2000", - "VMSA-2024-0002" - ], - "cvssMetrics": [], - "exploitKnown": false, - "language": "en", - "modified": "2024-04-02T09:00:00+00:00", - "provenance": [ - { - "fieldMask": [], - "kind": "document", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "https://vmware.example/api/vmsa/VMSA-2024-0002.json" - }, - { - "fieldMask": [], - "kind": "mapping", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "VMSA-2024-0002" - } - ], - "published": "2024-04-02T09:00:00+00:00", - "references": [ - { - "kind": "kb", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "https://kb.vmware.example/91234" - }, - "sourceTag": "kb", - "summary": null, - "url": "https://kb.vmware.example/91234" - }, - { - "kind": "advisory", - "provenance": { - "fieldMask": [], - "kind": "reference", - "recordedAt": "2024-04-05T00:00:00+00:00", - "source": "vmware", - "value": "https://www.vmware.com/security/advisories/VMSA-2024-0002.html" - }, - "sourceTag": "advisory", - "summary": null, - "url": "https://www.vmware.com/security/advisories/VMSA-2024-0002.html" - } - ], - "severity": null, - "summary": "An update is available for VMware Cloud Foundation components to address a remote code execution vulnerability.", - "title": "VMware Cloud Foundation remote code execution vulnerability" - } +[ + { + "advisoryKey": "VMSA-2024-0001", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "VMware ESXi 7.0", + "platform": null, + "versionRanges": [ + { + "fixedVersion": "7.0u3f", + "introducedVersion": "7.0", + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": null, + "fixedInclusive": false, + "introduced": "7.0", + "introducedInclusive": true, + "lastAffected": null, + "lastAffectedInclusive": false, + "style": "greaterThanOrEqual" + }, + "vendorExtensions": { + "vmware.product": "VMware ESXi 7.0", + "vmware.version.raw": "7.0", + "vmware.fixedVersion.raw": "7.0u3f" + } + }, + "provenance": { + "source": "vmware", + "kind": "range", + "value": "VMware ESXi 7.0", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "7.0", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vmware", + "kind": "affected", + "value": "VMware ESXi 7.0", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + } + ] + }, + { + "type": "vendor", + "identifier": "VMware vCenter Server 8.0", + "platform": null, + "versionRanges": [ + { + "fixedVersion": "8.0a", + "introducedVersion": "8.0", + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": null, + "fixedInclusive": false, + "introduced": "8.0", + "introducedInclusive": true, + "lastAffected": null, + "lastAffectedInclusive": false, + "style": "greaterThanOrEqual" + }, + "vendorExtensions": { + "vmware.product": "VMware vCenter Server 8.0", + "vmware.version.raw": "8.0", + "vmware.fixedVersion.raw": "8.0a" + } + }, + "provenance": { + "source": "vmware", + "kind": "range", + "value": "VMware vCenter Server 8.0", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "8.0", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vmware", + "kind": "affected", + "value": "VMware vCenter Server 8.0", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CVE-2024-1000", + "CVE-2024-1001", + "VMSA-2024-0001" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-04-01T10:00:00+00:00", + "provenance": [ + { + "source": "vmware", + "kind": "document", + "value": "https://vmware.example/api/vmsa/VMSA-2024-0001.json", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "vmware", + "kind": "mapping", + "value": "VMSA-2024-0001", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-04-01T10:00:00+00:00", + "references": [ + { + "kind": "kb", + "provenance": { + "source": "vmware", + "kind": "reference", + "value": "https://kb.vmware.example/90234", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "kb", + "summary": null, + "url": "https://kb.vmware.example/90234" + }, + { + "kind": "advisory", + "provenance": { + "source": "vmware", + "kind": "reference", + "value": "https://www.vmware.com/security/advisories/VMSA-2024-0001.html", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "advisory", + "summary": null, + "url": "https://www.vmware.com/security/advisories/VMSA-2024-0001.html" + } + ], + "severity": null, + "summary": "Security updates for VMware ESXi 7.0 and vCenter Server 8.0 resolve multiple vulnerabilities.", + "title": "VMware ESXi and vCenter Server updates address vulnerabilities" + }, + { + "advisoryKey": "VMSA-2024-0002", + "affectedPackages": [ + { + "type": "vendor", + "identifier": "VMware Cloud Foundation 5.x", + "platform": null, + "versionRanges": [ + { + "fixedVersion": "5.1.1", + "introducedVersion": "5.1", + "lastAffectedVersion": null, + "primitives": { + "evr": null, + "hasVendorExtensions": true, + "nevra": null, + "semVer": { + "constraintExpression": null, + "exactValue": null, + "fixed": "5.1.1", + "fixedInclusive": false, + "introduced": "5.1", + "introducedInclusive": true, + "lastAffected": null, + "lastAffectedInclusive": false, + "style": "range" + }, + "vendorExtensions": { + "vmware.product": "VMware Cloud Foundation 5.x", + "vmware.version.raw": "5.1", + "vmware.fixedVersion.raw": "5.1.1" + } + }, + "provenance": { + "source": "vmware", + "kind": "range", + "value": "VMware Cloud Foundation 5.x", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + "rangeExpression": "5.1", + "rangeKind": "vendor" + } + ], + "normalizedVersions": [], + "statuses": [], + "provenance": [ + { + "source": "vmware", + "kind": "affected", + "value": "VMware Cloud Foundation 5.x", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + } + ] + } + ], + "aliases": [ + "CVE-2024-2000", + "VMSA-2024-0002" + ], + "canonicalMetricId": null, + "credits": [], + "cvssMetrics": [], + "cwes": [], + "description": null, + "exploitKnown": false, + "language": "en", + "modified": "2024-04-02T09:00:00+00:00", + "provenance": [ + { + "source": "vmware", + "kind": "document", + "value": "https://vmware.example/api/vmsa/VMSA-2024-0002.json", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + { + "source": "vmware", + "kind": "mapping", + "value": "VMSA-2024-0002", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + } + ], + "published": "2024-04-02T09:00:00+00:00", + "references": [ + { + "kind": "kb", + "provenance": { + "source": "vmware", + "kind": "reference", + "value": "https://kb.vmware.example/91234", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "kb", + "summary": null, + "url": "https://kb.vmware.example/91234" + }, + { + "kind": "advisory", + "provenance": { + "source": "vmware", + "kind": "reference", + "value": "https://www.vmware.com/security/advisories/VMSA-2024-0002.html", + "decisionReason": null, + "recordedAt": "2024-04-05T00:00:00+00:00", + "fieldMask": [] + }, + "sourceTag": "advisory", + "summary": null, + "url": "https://www.vmware.com/security/advisories/VMSA-2024-0002.html" + } + ], + "severity": null, + "summary": "An update is available for VMware Cloud Foundation components to address a remote code execution vulnerability.", + "title": "VMware Cloud Foundation remote code execution vulnerability" + } ] \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Aoc/AdvisoryRawWriteGuardTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Aoc/AdvisoryRawWriteGuardTests.cs index 725783fa2..ee7cefe41 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Aoc/AdvisoryRawWriteGuardTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Aoc/AdvisoryRawWriteGuardTests.cs @@ -1,17 +1,20 @@ using System.Collections.Immutable; -using System.Text.Json; -using StellaOps.Aoc; -using StellaOps.Concelier.Core.Aoc; -using StellaOps.Concelier.RawModels; - -namespace StellaOps.Concelier.Core.Tests.Aoc; - -public sealed class AdvisoryRawWriteGuardTests -{ - private static AdvisoryRawDocument CreateDocument( - string tenant = "tenant-a", - bool signaturePresent = false, - bool includeSignaturePayload = true) +using System.Text.Json; +using Microsoft.Extensions.Options; +using StellaOps.Aoc; +using StellaOps.Concelier.Core.Aoc; +using StellaOps.Concelier.RawModels; + +namespace StellaOps.Concelier.Core.Tests.Aoc; + +public sealed class AdvisoryRawWriteGuardTests +{ + private static readonly AocGuardOptions GuardOptions = AocGuardOptions.Default; + + private static AdvisoryRawDocument CreateDocument( + string tenant = "tenant-a", + bool signaturePresent = false, + bool includeSignaturePayload = true) { using var rawDocument = JsonDocument.Parse("""{"id":"demo"}"""); var signature = signaturePresent @@ -22,11 +25,11 @@ public sealed class AdvisoryRawWriteGuardTests Signature: includeSignaturePayload ? "base64signature" : null) : new RawSignatureMetadata(false); - return new AdvisoryRawDocument( - Tenant: tenant, - Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"), - Upstream: new RawUpstreamMetadata( - UpstreamId: "GHSA-xxxx", + return new AdvisoryRawDocument( + Tenant: tenant, + Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"), + Upstream: new RawUpstreamMetadata( + UpstreamId: "GHSA-xxxx", DocumentVersion: "1", RetrievedAt: DateTimeOffset.UtcNow, ContentHash: "sha256:abc", @@ -36,47 +39,51 @@ public sealed class AdvisoryRawWriteGuardTests Format: "OSV", SpecVersion: "1.0", Raw: rawDocument.RootElement.Clone()), - Identifiers: new RawIdentifiers( - Aliases: ImmutableArray.Create("GHSA-xxxx"), - PrimaryId: "GHSA-xxxx"), - Linkset: new RawLinkset - { - Aliases = ImmutableArray.Empty, - PackageUrls = ImmutableArray.Empty, - Cpes = ImmutableArray.Empty, - References = ImmutableArray.Empty, - ReconciledFrom = ImmutableArray.Empty, - Notes = ImmutableDictionary.Empty - }); - } + Identifiers: new RawIdentifiers( + Aliases: ImmutableArray.Create("GHSA-xxxx"), + PrimaryId: "GHSA-xxxx"), + Linkset: new RawLinkset + { + Aliases = ImmutableArray.Empty, + PackageUrls = ImmutableArray.Empty, + Cpes = ImmutableArray.Empty, + References = ImmutableArray.Empty, + ReconciledFrom = ImmutableArray.Empty, + Notes = ImmutableDictionary.Empty + }, + Links: ImmutableArray.Empty); + } + + private static AdvisoryRawWriteGuard CreateGuard() + => new(new AocWriteGuard(), Options.Create(GuardOptions)); + + [Fact] + public void EnsureValid_AllowsMinimalDocument() + { + var guard = CreateGuard(); + var document = CreateDocument(); + + guard.EnsureValid(document); + } - [Fact] - public void EnsureValid_AllowsMinimalDocument() - { - var guard = new AdvisoryRawWriteGuard(new AocWriteGuard()); - var document = CreateDocument(); - - guard.EnsureValid(document); - } - - [Fact] - public void EnsureValid_ThrowsWhenTenantMissing() - { - var guard = new AdvisoryRawWriteGuard(new AocWriteGuard()); - var document = CreateDocument(tenant: string.Empty); - - var exception = Assert.Throws(() => guard.EnsureValid(document)); + [Fact] + public void EnsureValid_ThrowsWhenTenantMissing() + { + var guard = CreateGuard(); + var document = CreateDocument(tenant: string.Empty); + + var exception = Assert.Throws(() => guard.EnsureValid(document)); Assert.Equal("ERR_AOC_004", exception.PrimaryErrorCode); Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_004" && violation.Path == "/tenant"); } - [Fact] - public void EnsureValid_ThrowsWhenSignaturePayloadMissing() - { - var guard = new AdvisoryRawWriteGuard(new AocWriteGuard()); - var document = CreateDocument(signaturePresent: true, includeSignaturePayload: false); - - var exception = Assert.Throws(() => guard.EnsureValid(document)); + [Fact] + public void EnsureValid_ThrowsWhenSignaturePayloadMissing() + { + var guard = CreateGuard(); + var document = CreateDocument(signaturePresent: true, includeSignaturePayload: false); + + var exception = Assert.Throws(() => guard.EnsureValid(document)); Assert.Equal("ERR_AOC_005", exception.PrimaryErrorCode); Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_005"); } diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Events/AdvisoryEventLogTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Events/AdvisoryEventLogTests.cs index 2d737f8f4..ff1c1a7ca 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Events/AdvisoryEventLogTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Events/AdvisoryEventLogTests.cs @@ -53,11 +53,11 @@ public sealed class AdvisoryEventLogTests } [Fact] - public async Task AppendAsync_PersistsConflictsWithCanonicalizedJson() - { - var repository = new FakeRepository(); - var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-19T12:00:00Z")); - var log = new AdvisoryEventLog(repository, timeProvider); + public async Task AppendAsync_PersistsConflictsWithCanonicalizedJson() + { + var repository = new FakeRepository(); + var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-19T12:00:00Z")); + var log = new AdvisoryEventLog(repository, timeProvider); using var conflictJson = JsonDocument.Parse("{\"reason\":\"tie\",\"details\":{\"b\":2,\"a\":1}}"); var conflictInput = new AdvisoryConflictInput( @@ -73,13 +73,52 @@ public sealed class AdvisoryEventLogTests Assert.Equal("cve-2025-0001", entry.VulnerabilityKey); Assert.Equal("{\"details\":{\"a\":1,\"b\":2},\"reason\":\"tie\"}", entry.CanonicalJson); Assert.NotEqual(ImmutableArray.Empty, entry.ConflictHash); - Assert.Equal(DateTimeOffset.Parse("2025-10-04T00:00:00Z"), entry.AsOf); - } - - [Fact] - public async Task ReplayAsync_ReturnsSortedSnapshots() - { - var repository = new FakeRepository(); + Assert.Equal(DateTimeOffset.Parse("2025-10-04T00:00:00Z"), entry.AsOf); + } + + [Fact] + public async Task AppendAsync_SortsConflictStatementIds() + { + var repository = new FakeRepository(); + var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-20T12:00:00Z")); + var log = new AdvisoryEventLog(repository, timeProvider); + + var unordered = new[] + { + Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Guid.Empty, + Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), + Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + }; + + using var conflictJson = JsonDocument.Parse("{\"reason\":\"severity-mismatch\"}"); + var conflictInput = new AdvisoryConflictInput( + VulnerabilityKey: "CVE-2025-3000", + Details: conflictJson, + AsOf: DateTimeOffset.Parse("2025-10-20T00:00:00Z"), + StatementIds: unordered); + + await log.AppendAsync( + new AdvisoryEventAppendRequest(Array.Empty(), new[] { conflictInput }), + CancellationToken.None); + + var entry = Assert.Single(repository.InsertedConflicts); + Assert.Equal( + new[] + { + Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc") + }, + entry.StatementIds); + Assert.Equal("{\"reason\":\"severity-mismatch\"}", entry.CanonicalJson); + } + + [Fact] + public async Task ReplayAsync_ReturnsSortedSnapshots() + { + var repository = new FakeRepository(); var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-05T00:00:00Z")); var log = new AdvisoryEventLog(repository, timeProvider); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs index 704a49bf7..0ffd92f3b 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs @@ -122,19 +122,19 @@ public sealed class AdvisoryObservationFactoryTests var factory = new AdvisoryObservationFactory(); var notes = ImmutableDictionary.CreateRange(new Dictionary { - ["range-introduced"] = "1.0.0", - ["range-fixed"] = "1.0.5" - }); - - var rawDocument = BuildRawDocument( - identifiers: new RawIdentifiers(ImmutableArray.Empty, "primary"), - linkset: new RawLinkset - { - Notes = notes, - ReconciledFrom = ImmutableArray.Create("connector-a", "connector-b") - }, - supersedes: "tenant-a:vendor-x:previous:sha256:123"); - + ["range-introduced"] = "1.0.0", + ["range-fixed"] = "1.0.5" + }); + + var rawDocument = BuildRawDocument( + identifiers: new RawIdentifiers(ImmutableArray.Empty, "primary"), + linkset: new RawLinkset + { + Notes = notes, + ReconciledFrom = ImmutableArray.Create("connector-a", "connector-b") + }, + supersedes: "tenant-a:vendor-x:previous:sha256:123"); + var observation = factory.Create(rawDocument); Assert.Equal("1.0.0", observation.Attributes["linkset.note.range-introduced"]); @@ -145,6 +145,65 @@ public sealed class AdvisoryObservationFactoryTests Assert.Equal(new[] { "connector-a", "connector-b" }, observation.RawLinkset.ReconciledFrom); } + [Fact] + public void Create_PreservesRawReferencesForConflictAudits() + { + var factory = new AdvisoryObservationFactory(); + var references = ImmutableArray.Create( + new RawReference(" ADVISORY ", " https://example.test/advisory ", "vendor-feed"), + new RawReference("fix", "https://example.test/fix ", "vendor-feed")); + var notes = ImmutableDictionary.CreateRange(new Dictionary + { + ["conflict.primary"] = "critical", + ["conflict.suppressed"] = "medium" + }); + + var rawDocument = BuildRawDocument( + identifiers: new RawIdentifiers( + Aliases: ImmutableArray.Create("CVE-2025-2000"), + PrimaryId: "VENDOR-2000"), + linkset: new RawLinkset + { + References = references, + Notes = notes, + ReconciledFrom = ImmutableArray.Create("/content/raw/severity", "/content/raw/status") + }); + + var observation = factory.Create(rawDocument, SampleTimestamp); + + Assert.Collection( + observation.Linkset.References, + first => + { + Assert.Equal("ADVISORY", first.Type); + Assert.Equal("https://example.test/advisory", first.Url); + }, + second => + { + Assert.Equal("fix", second.Type); + Assert.Equal("https://example.test/fix", second.Url); + }); + + Assert.Collection( + observation.RawLinkset.References, + first => + { + Assert.Equal(" ADVISORY ", first.Type); + Assert.Equal(" https://example.test/advisory ", first.Url); + Assert.Equal("vendor-feed", first.Source); + }, + second => + { + Assert.Equal("fix", second.Type); + Assert.Equal("https://example.test/fix ", second.Url); + Assert.Equal("vendor-feed", second.Source); + }); + + Assert.Equal("critical", observation.Attributes["linkset.note.conflict.primary"]); + Assert.Equal("medium", observation.Attributes["linkset.note.conflict.suppressed"]); + Assert.Equal("/content/raw/severity;/content/raw/status", observation.Attributes["linkset.reconciled_from"]); + } + [Fact] public void Create_IsDeterministicAcrossRuns() { diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Raw/AdvisoryRawServiceTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Raw/AdvisoryRawServiceTests.cs index 4310a7763..9ef121252 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Raw/AdvisoryRawServiceTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Raw/AdvisoryRawServiceTests.cs @@ -1,30 +1,34 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; -using StellaOps.Aoc; -using StellaOps.Concelier.Core.Aoc; -using StellaOps.Concelier.Core.Linksets; -using StellaOps.Concelier.Core.Raw; -using StellaOps.Concelier.RawModels; -using Xunit; - -namespace StellaOps.Concelier.Core.Tests.Raw; - -public sealed class AdvisoryRawServiceTests -{ - [Fact] - public async Task IngestAsync_RemovesClientSupersedesBeforeUpsert() - { - var repository = new RecordingRepository(); - var service = CreateService(repository); - +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Aoc; +using StellaOps.Concelier.Core.Aoc; +using StellaOps.Concelier.Core.Linksets; +using StellaOps.Concelier.Core.Raw; +using StellaOps.Concelier.RawModels; +using StellaOps.Ingestion.Telemetry; +using Xunit; + +namespace StellaOps.Concelier.Core.Tests.Raw; + +public sealed class AdvisoryRawServiceTests +{ + private const string GhsaAlias = "GHSA-AAAA-BBBB-CCCC"; + + [Fact] + public async Task IngestAsync_RemovesClientSupersedesBeforeUpsert() + { + var repository = new RecordingRepository(); + var service = CreateService(repository); + var document = CreateDocument() with { Supersedes = " previous-id " }; - var storedDocument = document.WithSupersedes("advisory_raw:vendor-x:ghsa-xxxx:sha256-2"); + var storedDocument = document.WithSupersedes("advisory_raw:vendor-x:ghsa-aaaa-bbbb-cccc:sha256-2"); var expectedResult = new AdvisoryRawUpsertResult(true, CreateRecord(storedDocument)); repository.NextResult = expectedResult; @@ -33,12 +37,14 @@ public sealed class AdvisoryRawServiceTests Assert.NotNull(repository.CapturedDocument); Assert.Null(repository.CapturedDocument!.Supersedes); Assert.Equal(expectedResult.Record.Document.Supersedes, result.Record.Document.Supersedes); - Assert.Equal("GHSA-XXXX", repository.CapturedDocument.AdvisoryKey); - Assert.Contains(repository.CapturedDocument.Links, link => link.Scheme == "GHSA" && link.Value == "GHSA-XXXX"); - Assert.Contains(repository.CapturedDocument.Links, link => link.Scheme == "PRIMARY" && link.Value == "GHSA-XXXX"); - } - - [Fact] + Assert.Equal("GHSA-AAAA-BBBB-CCCC", repository.CapturedDocument.AdvisoryKey, ignoreCase: true); + Assert.Contains(repository.CapturedDocument.Links, link => + string.Equals(link.Value, "GHSA-AAAA-BBBB-CCCC", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(repository.CapturedDocument.Links, link => + link.Scheme == "PRIMARY" && string.Equals(link.Value, "GHSA-AAAA-BBBB-CCCC", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] public async Task IngestAsync_PropagatesRepositoryDuplicateResult() { var repository = new RecordingRepository(); @@ -54,16 +60,66 @@ public sealed class AdvisoryRawServiceTests Assert.Same(expectedResult.Record, result.Record); } + [Fact] + public async Task IngestAsync_EmitsWriteMetric() + { + var repository = new RecordingRepository(); + repository.NextResult = new AdvisoryRawUpsertResult(true, CreateRecord(CreateDocument())); + var service = CreateService(repository); + + var measurements = await CollectCounterMeasurementsAsync( + "ingestion_write_total", + () => service.IngestAsync(CreateDocument(), CancellationToken.None)); + + Assert.Contains( + measurements, + tags => string.Equals(GetTagValue(tags, "tenant") as string, "tenant-a", StringComparison.OrdinalIgnoreCase) + && string.Equals(GetTagValue(tags, "result") as string, IngestionTelemetry.ResultOk, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task IngestAsync_EmitsViolationMetricWhenGuardFails() + { + var repository = new RecordingRepository(); + var service = CreateService(repository, new ThrowingWriteGuard()); + + var violationMeasurements = await CollectCounterMeasurementsAsync( + "aoc_violation_total", + async () => + { + await Assert.ThrowsAsync( + () => service.IngestAsync(CreateDocument(), CancellationToken.None)); + }); + + Assert.Contains( + violationMeasurements, + tags => string.Equals(GetTagValue(tags, "tenant") as string, "tenant-a", StringComparison.OrdinalIgnoreCase) + && string.Equals(GetTagValue(tags, "code") as string, "ERR_AOC_001", StringComparison.OrdinalIgnoreCase)); + + var writeMeasurements = await CollectCounterMeasurementsAsync( + "ingestion_write_total", + async () => + { + await Assert.ThrowsAsync( + () => service.IngestAsync(CreateDocument(), CancellationToken.None)); + }); + + Assert.Contains( + writeMeasurements, + tags => string.Equals(GetTagValue(tags, "tenant") as string, "tenant-a", StringComparison.OrdinalIgnoreCase) + && string.Equals(GetTagValue(tags, "result") as string, IngestionTelemetry.ResultReject, StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task IngestAsync_PreservesAliasOrderAndDuplicates() { var repository = new RecordingRepository(); var service = CreateService(repository); - var aliasSeries = ImmutableArray.Create("CVE-2025-0001", "CVE-2025-0001", "GHSA-xxxx", "cve-2025-0001"); + var aliasSeries = ImmutableArray.Create("CVE-2025-0001", "CVE-2025-0001", GhsaAlias, "cve-2025-0001"); var document = CreateDocument() with { - Identifiers = new RawIdentifiers(aliasSeries, "GHSA-xxxx"), + Identifiers = new RawIdentifiers(aliasSeries, GhsaAlias), }; repository.NextResult = new AdvisoryRawUpsertResult(true, CreateRecord(document)); @@ -74,7 +130,8 @@ public sealed class AdvisoryRawServiceTests Assert.True(aliasSeries.SequenceEqual(repository.CapturedDocument!.Identifiers.Aliases)); Assert.Equal("CVE-2025-0001", repository.CapturedDocument.AdvisoryKey); Assert.Contains(repository.CapturedDocument.Links, link => link.Scheme == "CVE" && link.Value == "CVE-2025-0001"); - Assert.Contains(repository.CapturedDocument.Links, link => link.Scheme == "GHSA" && link.Value == "GHSA-XXXX"); + Assert.Contains(repository.CapturedDocument.Links, link => + string.Equals(link.Value, "GHSA-AAAA-BBBB-CCCC", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -88,38 +145,42 @@ public sealed class AdvisoryRawServiceTests var results = await service.FindByAdvisoryKeyAsync( "Tenant-Example", - "ghsa-xxxx", + "ghsa-aaaa-bbbb-cccc", new[] { "Vendor-X", " " }, CancellationToken.None); Assert.Single(results); Assert.Equal("tenant-example", repository.CapturedTenant); - Assert.Contains("GHSA-XXXX", repository.CapturedAdvisoryKeySearchValues!, StringComparer.Ordinal); - Assert.Contains("ghsa-xxxx", repository.CapturedAdvisoryKeySearchValues!, StringComparer.Ordinal); + Assert.Contains("GHSA-AAAA-BBBB-CCCC", repository.CapturedAdvisoryKeySearchValues!, StringComparer.OrdinalIgnoreCase); + Assert.Contains("ghsa-aaaa-bbbb-cccc", repository.CapturedAdvisoryKeySearchValues!, StringComparer.Ordinal); Assert.Contains("vendor-x", repository.CapturedAdvisoryKeyVendors!, StringComparer.Ordinal); } - private static AdvisoryRawService CreateService(RecordingRepository repository) - { - var writeGuard = new AdvisoryRawWriteGuard(new AocWriteGuard()); - var linksetMapper = new PassthroughLinksetMapper(); - return new AdvisoryRawService( - repository, - writeGuard, - new AocWriteGuard(), - linksetMapper, - TimeProvider.System, - NullLogger.Instance); - } - - private static AdvisoryRawDocument CreateDocument() - { - using var raw = JsonDocument.Parse("""{"id":"demo"}"""); + private static AdvisoryRawService CreateService( + RecordingRepository repository, + IAdvisoryRawWriteGuard? writeGuard = null, + IAocGuard? aocGuard = null) + { + var guard = aocGuard ?? new AocWriteGuard(); + var resolvedWriteGuard = writeGuard ?? new NoOpWriteGuard(); + var linksetMapper = new PassthroughLinksetMapper(); + return new AdvisoryRawService( + repository, + resolvedWriteGuard, + guard, + linksetMapper, + TimeProvider.System, + NullLogger.Instance); + } + + private static AdvisoryRawDocument CreateDocument() + { + using var raw = JsonDocument.Parse("""{"id":"demo"}"""); return new AdvisoryRawDocument( Tenant: "Tenant-A", Source: new RawSourceMetadata("Vendor-X", "connector-y", "1.0.0"), Upstream: new RawUpstreamMetadata( - UpstreamId: "GHSA-xxxx", + UpstreamId: GhsaAlias, DocumentVersion: "1", RetrievedAt: DateTimeOffset.UtcNow, ContentHash: "sha256:abc", @@ -134,8 +195,8 @@ public sealed class AdvisoryRawServiceTests SpecVersion: "1.0", Raw: raw.RootElement.Clone()), Identifiers: new RawIdentifiers( - Aliases: ImmutableArray.Create("GHSA-xxxx"), - PrimaryId: "GHSA-xxxx"), + Aliases: ImmutableArray.Create(GhsaAlias), + PrimaryId: GhsaAlias), Linkset: new RawLinkset { Aliases = ImmutableArray.Empty, @@ -148,7 +209,7 @@ public sealed class AdvisoryRawServiceTests AdvisoryKey: string.Empty, Links: ImmutableArray.Empty); } - + private static AdvisoryRawRecord CreateRecord(AdvisoryRawDocument document) { var canonical = AdvisoryCanonicalizer.Canonicalize(document.Identifiers, document.Source, document.Upstream); @@ -159,13 +220,13 @@ public sealed class AdvisoryRawServiceTests }; return new AdvisoryRawRecord( - Id: "advisory_raw:vendor-x:ghsa-xxxx:sha256-1", + Id: "advisory_raw:vendor-x:ghsa-aaaa-bbbb-cccc:sha256-1", Document: resolvedDocument, IngestedAt: DateTimeOffset.UtcNow, CreatedAt: document.Upstream.RetrievedAt); } - - private sealed class RecordingRepository : IAdvisoryRawRepository + + private sealed class RecordingRepository : IAdvisoryRawRepository { public AdvisoryRawDocument? CapturedDocument { get; private set; } @@ -184,15 +245,15 @@ public sealed class AdvisoryRawServiceTests if (NextResult is null) { throw new InvalidOperationException("NextResult must be set before calling UpsertAsync."); - } - - CapturedDocument = document; - return Task.FromResult(NextResult); - } - - public Task FindByIdAsync(string tenant, string id, CancellationToken cancellationToken) - => throw new NotSupportedException(); - + } + + CapturedDocument = document; + return Task.FromResult(NextResult); + } + + public Task FindByIdAsync(string tenant, string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + public Task QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken) => throw new NotSupportedException(); @@ -213,12 +274,73 @@ public sealed class AdvisoryRawServiceTests DateTimeOffset since, DateTimeOffset until, IReadOnlyCollection sourceVendors, - CancellationToken cancellationToken) - => throw new NotSupportedException(); - } - - private sealed class PassthroughLinksetMapper : IAdvisoryLinksetMapper - { - public RawLinkset Map(AdvisoryRawDocument document) => document.Linkset; - } -} + CancellationToken cancellationToken) + => throw new NotSupportedException(); + } + + private static async Task[]>> CollectCounterMeasurementsAsync( + string instrumentName, + Func action) + { + var measurements = new List[]>(); + using var listener = new MeterListener(); + listener.InstrumentPublished += (instrument, meterListener) => + { + if (instrument.Meter.Name == IngestionTelemetry.MeterName && instrument.Name == instrumentName) + { + meterListener.EnableMeasurementEvents(instrument); + } + }; + + listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + { + if (instrument.Name == instrumentName) + { + measurements.Add(tags.ToArray()); + } + }); + + listener.Start(); + await action().ConfigureAwait(false); + return measurements; + } + + private static object? GetTagValue(IEnumerable> tags, string key) + { + foreach (var tag in tags) + { + if (string.Equals(tag.Key, key, StringComparison.Ordinal)) + { + return tag.Value; + } + } + + return null; + } + + private sealed class ThrowingWriteGuard : IAdvisoryRawWriteGuard + { + public void EnsureValid(AdvisoryRawDocument document) + { + var violation = AocViolation.Create( + AocViolationCode.ForbiddenField, + "/content/raw", + "Forbidden derived data detected"); + var result = AocGuardResult.FromViolations(new[] { violation }); + throw new ConcelierAocGuardException(result); + } + } + + private sealed class NoOpWriteGuard : IAdvisoryRawWriteGuard + { + public void EnsureValid(AdvisoryRawDocument document) + { + // Intentionally left blank for tests. + } + } + + private sealed class PassthroughLinksetMapper : IAdvisoryLinksetMapper + { + public RawLinkset Map(AdvisoryRawDocument document) => document.Linkset; + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj index 6ff4710f3..e67800bde 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj @@ -8,6 +8,7 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExportSnapshotBuilderTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExportSnapshotBuilderTests.cs index 108a4809b..2b506e430 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExportSnapshotBuilderTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExportSnapshotBuilderTests.cs @@ -1,14 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using StellaOps.Concelier.Exporter.Json; -using StellaOps.Concelier.Models; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Concelier.Exporter.Json; +using StellaOps.Concelier.Models; +using StellaOps.Cryptography; namespace StellaOps.Concelier.Exporter.Json.Tests; @@ -82,26 +83,52 @@ public sealed class JsonExportSnapshotBuilderTests : IDisposable } [Fact] - public async Task WriteAsync_NormalizesInputOrdering() - { - var options = new JsonExportOptions { OutputRoot = _root }; - var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); - var exportedAt = DateTimeOffset.Parse("2024-06-01T00:00:00Z", CultureInfo.InvariantCulture); + public async Task WriteAsync_NormalizesInputOrdering() + { + var options = new JsonExportOptions { OutputRoot = _root }; + var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); + var exportedAt = DateTimeOffset.Parse("2024-06-01T00:00:00Z", CultureInfo.InvariantCulture); var advisoryA = CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000" }, "Alpha", "high"); var advisoryB = CreateAdvisory("VENDOR-0001", new[] { "VENDOR-0001" }, "Vendor Advisory", "medium"); var result = await builder.WriteAsync(new[] { advisoryB, advisoryA }, exportedAt, cancellationToken: CancellationToken.None); - var expectedOrder = result.FilePaths.OrderBy(path => path, StringComparer.Ordinal).ToArray(); - Assert.Equal(expectedOrder, result.FilePaths.ToArray()); - } - - [Fact] - public async Task WriteAsync_EnumeratesStreamOnlyOnce() - { - var options = new JsonExportOptions { OutputRoot = _root }; - var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); + var expectedOrder = result.FilePaths.OrderBy(path => path, StringComparer.Ordinal).ToArray(); + Assert.Equal(expectedOrder, result.FilePaths.ToArray()); + } + + [Fact] + public async Task WriteAsync_DifferentInputOrderProducesSameDigest() + { + var options = new JsonExportOptions { OutputRoot = _root }; + var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); + var exportedAt = DateTimeOffset.Parse("2024-06-15T00:00:00Z", CultureInfo.InvariantCulture); + + var advisoryA = CreateAdvisory("CVE-2024-1100", new[] { "CVE-2024-1100" }, "Alpha", "critical"); + var advisoryB = CreateAdvisory("VENDOR-2024-42", new[] { "VENDOR-2024-42" }, "Vendor", "medium"); + + var first = await builder.WriteAsync( + new[] { advisoryA, advisoryB }, + exportedAt, + exportName: "order-a", + cancellationToken: CancellationToken.None); + var second = await builder.WriteAsync( + new[] { advisoryB, advisoryA }, + exportedAt, + exportName: "order-b", + cancellationToken: CancellationToken.None); + + Assert.Equal( + Convert.ToHexString(ComputeDigest(first)), + Convert.ToHexString(ComputeDigest(second))); + } + + [Fact] + public async Task WriteAsync_EnumeratesStreamOnlyOnce() + { + var options = new JsonExportOptions { OutputRoot = _root }; + var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); var exportedAt = DateTimeOffset.Parse("2024-08-01T00:00:00Z", CultureInfo.InvariantCulture); var advisories = new[] @@ -150,19 +177,20 @@ public sealed class JsonExportSnapshotBuilderTests : IDisposable }); } - private static byte[] ComputeDigest(JsonExportResult result) - { - using var sha256 = SHA256.Create(); - foreach (var relative in result.FilePaths.OrderBy(x => x, StringComparer.Ordinal)) - { - var fullPath = ResolvePath(result.ExportDirectory, relative); - var bytes = File.ReadAllBytes(fullPath); - sha256.TransformBlock(bytes, 0, bytes.Length, null, 0); - } - - sha256.TransformFinalBlock(Array.Empty(), 0, 0); - return sha256.Hash ?? Array.Empty(); - } + private static byte[] ComputeDigest(JsonExportResult result) + { + var hash = CryptoHashFactory.CreateDefault(); + var buffer = new ArrayBufferWriter(); + + foreach (var relative in result.FilePaths.OrderBy(x => x, StringComparer.Ordinal)) + { + var fullPath = ResolvePath(result.ExportDirectory, relative); + var bytes = File.ReadAllBytes(fullPath); + buffer.Write(bytes); + } + + return hash.ComputeHash(buffer.WrittenSpan, HashAlgorithms.Sha256); + } private static string ResolvePath(string root, string relative) { diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExporterDependencyInjectionRoutineTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExporterDependencyInjectionRoutineTests.cs index 05a9175a2..fd5ddd591 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExporterDependencyInjectionRoutineTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonExporterDependencyInjectionRoutineTests.cs @@ -13,6 +13,8 @@ using StellaOps.Concelier.Exporter.Json; using StellaOps.Concelier.Storage.Mongo.Advisories; using StellaOps.Concelier.Storage.Mongo.Exporting; using StellaOps.Concelier.Models; +using StellaOps.Cryptography; +using StellaOps.Cryptography.DependencyInjection; namespace StellaOps.Concelier.Exporter.Json.Tests; @@ -26,7 +28,10 @@ public sealed class JsonExporterDependencyInjectionRoutineTests services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddOptions(); services.AddOptions(); + services.Configure(_ => { }); + services.AddStellaOpsCrypto(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary()) diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonFeedExporterTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonFeedExporterTests.cs index c9dfc3098..02b74e03d 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonFeedExporterTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/JsonFeedExporterTests.cs @@ -10,16 +10,17 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using MongoDB.Driver; -using StellaOps.Concelier.Core.Events; -using StellaOps.Concelier.Exporter.Json; -using StellaOps.Concelier.Models; -using StellaOps.Concelier.Storage.Mongo.Advisories; -using StellaOps.Concelier.Storage.Mongo.Exporting; -using StellaOps.Cryptography; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using StellaOps.Concelier.Core.Events; +using StellaOps.Concelier.Exporter.Json; +using StellaOps.Concelier.Models; +using StellaOps.Concelier.Storage.Mongo.Advisories; +using StellaOps.Concelier.Storage.Mongo.Exporting; +using StellaOps.Cryptography; +using StellaOps.Cryptography.DependencyInjection; namespace StellaOps.Concelier.Exporter.Json.Tests; @@ -70,7 +71,7 @@ public sealed class JsonFeedExporterTests : IDisposable NullLogger.Instance, timeProvider); - using var provider = new ServiceCollection().BuildServiceProvider(); + using var provider = CreateCryptoProvider(); await exporter.ExportAsync(provider, CancellationToken.None); var record = await stateStore.FindAsync(JsonFeedExporter.ExporterId, CancellationToken.None); @@ -164,7 +165,7 @@ public sealed class JsonFeedExporterTests : IDisposable NullLogger.Instance, timeProvider); - using var provider = new ServiceCollection().BuildServiceProvider(); + using var provider = CreateCryptoProvider(); await exporter.ExportAsync(provider, CancellationToken.None); var exportId = exportedAt.ToString(optionsValue.DirectoryNameFormat, CultureInfo.InvariantCulture); @@ -322,16 +323,7 @@ public sealed class JsonFeedExporterTests : IDisposable NullLogger.Instance, timeProvider); - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => - { - var provider = sp.GetRequiredService(); - return new CryptoProviderRegistry(new[] { provider }); - }); - - using var provider = services.BuildServiceProvider(); + using var provider = CreateCryptoProvider(); await exporter.ExportAsync(provider, CancellationToken.None); var exportId = exportedAt.ToString(optionsValue.DirectoryNameFormat, CultureInfo.InvariantCulture); @@ -449,7 +441,7 @@ public sealed class JsonFeedExporterTests : IDisposable return $"-----BEGIN {label}-----\n{base64}\n-----END {label}-----\n"; } - private static byte[] BuildSigningInput(string protectedHeader, byte[] payload) + private static byte[] BuildSigningInput(string protectedHeader, byte[] payload) { var headerBytes = Encoding.ASCII.GetBytes(protectedHeader); var buffer = new byte[headerBytes.Length + 1 + payload.Length]; @@ -459,9 +451,9 @@ public sealed class JsonFeedExporterTests : IDisposable return buffer; } - private static byte[] Base64UrlDecode(string value) - { - var builder = new StringBuilder(value.Length + 3); + private static byte[] Base64UrlDecode(string value) + { + var builder = new StringBuilder(value.Length + 3); foreach (var ch in value) { builder.Append(ch switch @@ -475,10 +467,19 @@ public sealed class JsonFeedExporterTests : IDisposable while (builder.Length % 4 != 0) { builder.Append('='); - } - - return Convert.FromBase64String(builder.ToString()); - } + } + + return Convert.FromBase64String(builder.ToString()); + } + + private static ServiceProvider CreateCryptoProvider() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(_ => { }); + services.AddStellaOpsCrypto(); + return services.BuildServiceProvider(); + } private sealed class StubAdvisoryStore : IAdvisoryStore { @@ -594,4 +595,4 @@ public sealed class JsonFeedExporterTests : IDisposable public void Advance(TimeSpan delta) => _now = _now.Add(delta); } -} +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/StellaOps.Concelier.Exporter.Json.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/StellaOps.Concelier.Exporter.Json.Tests.csproj index 047120352..eb00de955 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/StellaOps.Concelier.Exporter.Json.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Concelier.Exporter.Json.Tests/StellaOps.Concelier.Exporter.Json.Tests.csproj @@ -10,5 +10,6 @@ + - \ No newline at end of file + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs index bcfb82a36..d553b985c 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs @@ -67,6 +67,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime { _runner = MongoDbRunner.Start(singleNodeReplSet: true); _factory = new ConcelierApplicationFactory(_runner.ConnectionString); + WarmupFactory(_factory); return Task.CompletedTask; } @@ -670,7 +671,10 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-violation"); - var invalidRequest = BuildAdvisoryIngestRequest(contentHash: string.Empty, upstreamId: "GHSA-INVALID-1"); + var invalidRequest = BuildAdvisoryIngestRequest( + contentHash: string.Empty, + upstreamId: "GHSA-INVALID-1", + enforceContentHash: false); var response = await client.PostAsJsonAsync("/ingest/advisory", invalidRequest); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); @@ -1361,10 +1365,22 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime var snapshot = documents?.ToArray() ?? Array.Empty(); if (snapshot.Length == 0) { + await collection.InsertManyAsync(snapshot); return; } await collection.InsertManyAsync(snapshot); + + var rawDocuments = snapshot + .Select(doc => CreateAdvisoryRawDocument( + doc.Tenant, + doc.Source.Vendor, + doc.Id, + doc.Upstream.ContentHash, + doc.Content.Raw.DeepClone().AsBsonDocument)) + .ToArray(); + + await SeedAdvisoryRawDocumentsAsync(rawDocuments); } private static AdvisoryObservationDocument[] BuildSampleObservationDocuments() @@ -1501,6 +1517,11 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime return value ?? string.Empty; } + if (!string.IsNullOrWhiteSpace(value)) + { + return value.Trim(); + } + using var sha256 = SHA256.Create(); var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(raw.GetRawText())); return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}"; @@ -1973,10 +1994,18 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime } } - private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(string? contentHash, string upstreamId) + private static void WarmupFactory(WebApplicationFactory factory) { - var normalizedContentHash = contentHash ?? ComputeDeterministicContentHash(upstreamId); - var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DateTime.UtcNow:O}""}}"); + using var client = factory.CreateClient(); + } + + private static AdvisoryIngestRequest BuildAdvisoryIngestRequest( + string? contentHash, + string upstreamId, + bool enforceContentHash = true) + { + var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DefaultIngestTimestamp:O}""}}"); + var normalizedContentHash = NormalizeContentHash(contentHash, raw, enforceContentHash); var references = new[] { new AdvisoryLinksetReferenceRequest("advisory", $"https://example.test/advisories/{upstreamId}", null) diff --git a/src/EvidenceLocker/StellaOps.EvidenceLocker/TASKS.md b/src/EvidenceLocker/StellaOps.EvidenceLocker/TASKS.md index 5b84799f6..749f1dfb6 100644 --- a/src/EvidenceLocker/StellaOps.EvidenceLocker/TASKS.md +++ b/src/EvidenceLocker/StellaOps.EvidenceLocker/TASKS.md @@ -6,6 +6,7 @@ | EVID-OBS-53-001 | DONE (2025-11-03) | Evidence Locker Guild | TELEMETRY-OBS-50-001, DEVOPS-OBS-50-003 | Bootstrap `StellaOps.Evidence.Locker` service with Postgres schema for `evidence_bundles`, `evidence_artifacts`, `evidence_holds`, tenant RLS, and object-store abstraction (WORM optional). | Service builds/tests; migrations deterministic; storage abstraction has local filesystem + S3 drivers; compliance checklist recorded. | | EVID-OBS-53-002 | DONE (2025-11-03) | Evidence Locker Guild, Orchestrator Guild | EVID-OBS-53-001, ORCH-OBS-53-001 | Implement bundle builders for evaluation/job/export snapshots collecting inputs, outputs, env digests, run metadata. Generate Merkle tree + manifest skeletons and persist root hash. | Builders cover three bundle types; integration tests verify deterministic manifests; root hash stored; docs stubbed. | | EVID-OBS-53-003 | DONE (2025-11-03) | Evidence Locker Guild, Security Guild | EVID-OBS-53-002 | Expose REST APIs (`POST /evidence/snapshot`, `GET /evidence/:id`, `POST /evidence/verify`, `POST /evidence/hold/:case_id`) with audit logging, tenant enforcement, and size quotas. | APIs documented via OpenAPI; tests cover RBAC/legal hold; size quota rejection returns structured error; audit logs validated. | +| EVID-CRYPTO-90-001 `Crypto provider adoption` | TODO | Evidence Locker Guild, Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Route bundle hashing/signing (manifest digests, DSSE assembly, export packaging) through `ICryptoProviderRegistry`/`ICryptoHash` per `docs/security/crypto-routing-audit-2025-11-07.md`. | Evidence bundles and sealing flows respect registry profile ordering (default + ru-offline); tests capture deterministic digests; docs updated with sovereign configuration steps. | ## Sprint 54 – Provenance Integration | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | diff --git a/src/Excititor/StellaOps.Excititor.WebService/AGENTS.md b/src/Excititor/StellaOps.Excititor.WebService/AGENTS.md index e39d7d846..f6aa4d460 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/AGENTS.md +++ b/src/Excititor/StellaOps.Excititor.WebService/AGENTS.md @@ -6,6 +6,7 @@ ASP.NET Minimal API surface for Excititor ingest, provider administration, recon - HTTP endpoints `/excititor/*` with authentication, authorization scopes, request validation, and deterministic responses. - Job orchestration bridges for Worker hand-off (when co-hosted) and offline-friendly configuration. - Observability (structured logs, metrics, tracing) aligned with StellaOps conventions. +- Optional/minor DI dependencies on minimal APIs must be declared with `[FromServices] SomeType? service = null` parameters so endpoint tests do not require bespoke service registrations. ## Participants - StellaOps.Cli sends `excititor` verbs to this service via token-authenticated HTTPS. - Worker receives scheduled jobs and uses shared infrastructure via common DI extensions. diff --git a/src/Excititor/StellaOps.Excititor.WebService/Contracts/VexRawContracts.cs b/src/Excititor/StellaOps.Excititor.WebService/Contracts/VexRawContracts.cs new file mode 100644 index 000000000..66ee4fda2 --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Contracts/VexRawContracts.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using StellaOps.Concelier.RawModels; +using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument; + +namespace StellaOps.Excititor.WebService.Contracts; + +public sealed record VexIngestRequest( + [property: JsonPropertyName("providerId")] string ProviderId, + [property: JsonPropertyName("source")] VexIngestSourceRequest Source, + [property: JsonPropertyName("upstream")] VexIngestUpstreamRequest Upstream, + [property: JsonPropertyName("content")] VexIngestContentRequest Content, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary? Metadata); + +public sealed record VexIngestSourceRequest( + [property: JsonPropertyName("vendor")] string Vendor, + [property: JsonPropertyName("connector")] string Connector, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("stream")] string? Stream); + +public sealed record VexIngestUpstreamRequest( + [property: JsonPropertyName("sourceUri")] string SourceUri, + [property: JsonPropertyName("upstreamId")] string UpstreamId, + [property: JsonPropertyName("documentVersion")] string? DocumentVersion, + [property: JsonPropertyName("retrievedAt")] DateTimeOffset? RetrievedAt, + [property: JsonPropertyName("contentHash")] string? ContentHash, + [property: JsonPropertyName("signature")] VexIngestSignatureRequest? Signature, + [property: JsonPropertyName("provenance")] IReadOnlyDictionary? Provenance); + +public sealed record VexIngestSignatureRequest( + [property: JsonPropertyName("present")] bool Present, + [property: JsonPropertyName("format")] string? Format, + [property: JsonPropertyName("keyId")] string? KeyId, + [property: JsonPropertyName("sig")] string? Signature, + [property: JsonPropertyName("certificate")] string? Certificate, + [property: JsonPropertyName("digest")] string? Digest); + +public sealed record VexIngestContentRequest( + [property: JsonPropertyName("format")] string Format, + [property: JsonPropertyName("specVersion")] string? SpecVersion, + [property: JsonPropertyName("raw")] JsonElement Raw, + [property: JsonPropertyName("encoding")] string? Encoding); + +public sealed record VexIngestResponse( + [property: JsonPropertyName("digest")] string Digest, + [property: JsonPropertyName("inserted")] bool Inserted, + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("retrievedAt")] DateTimeOffset RetrievedAt); + +public sealed record VexRawSummaryResponse( + [property: JsonPropertyName("digest")] string Digest, + [property: JsonPropertyName("providerId")] string ProviderId, + [property: JsonPropertyName("format")] string Format, + [property: JsonPropertyName("sourceUri")] string SourceUri, + [property: JsonPropertyName("retrievedAt")] DateTimeOffset RetrievedAt, + [property: JsonPropertyName("inlineContent")] bool InlineContent, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary Metadata); + +public sealed record VexRawListResponse( + [property: JsonPropertyName("records")] IReadOnlyList Records, + [property: JsonPropertyName("nextCursor")] string? NextCursor, + [property: JsonPropertyName("hasMore")] bool HasMore); + +public sealed record VexRawRecordResponse( + [property: JsonPropertyName("digest")] string Digest, + [property: JsonPropertyName("document")] RawVexDocumentModel Document, + [property: JsonPropertyName("retrievedAt")] DateTimeOffset RetrievedAt); + +public sealed record VexRawProvenanceResponse( + [property: JsonPropertyName("digest")] string Digest, + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("source")] RawSourceMetadata Source, + [property: JsonPropertyName("upstream")] RawUpstreamMetadata Upstream, + [property: JsonPropertyName("retrievedAt")] DateTimeOffset RetrievedAt); + +public sealed record VexAocVerifyRequest( + [property: JsonPropertyName("since")] DateTimeOffset? Since, + [property: JsonPropertyName("until")] DateTimeOffset? Until, + [property: JsonPropertyName("limit")] int? Limit, + [property: JsonPropertyName("sources")] IReadOnlyList? Sources, + [property: JsonPropertyName("codes")] IReadOnlyList? Codes); + +public sealed record VexAocVerifyResponse( + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("window")] VexAocVerifyWindow Window, + [property: JsonPropertyName("checked")] VexAocVerifyChecked Checked, + [property: JsonPropertyName("violations")] IReadOnlyList Violations, + [property: JsonPropertyName("metrics")] VexAocVerifyMetrics Metrics, + [property: JsonPropertyName("truncated")] bool Truncated); + +public sealed record VexAocVerifyWindow( + [property: JsonPropertyName("from")] DateTimeOffset From, + [property: JsonPropertyName("to")] DateTimeOffset To); + +public sealed record VexAocVerifyChecked( + [property: JsonPropertyName("advisories")] int Advisories, + [property: JsonPropertyName("vex")] int Vex); + +public sealed record VexAocVerifyMetrics( + [property: JsonPropertyName("ingestion_write_total")] int IngestionWriteTotal, + [property: JsonPropertyName("aoc_violation_total")] int AocViolationTotal); + +public sealed record VexAocVerifyViolation( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("examples")] IReadOnlyList Examples); + +public sealed record VexAocVerifyViolationExample( + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("documentId")] string DocumentId, + [property: JsonPropertyName("contentHash")] string ContentHash, + [property: JsonPropertyName("path")] string Path); diff --git a/src/Excititor/StellaOps.Excititor.WebService/Extensions/ObservabilityExtensions.cs b/src/Excititor/StellaOps.Excititor.WebService/Extensions/ObservabilityExtensions.cs new file mode 100644 index 000000000..63da80865 --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Extensions/ObservabilityExtensions.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using System.Threading.Tasks; + +namespace StellaOps.Excititor.WebService.Extensions; + +internal static class ObservabilityExtensions +{ + private const string TraceHeaderName = "X-Stella-TraceId"; + private const string CorrelationHeaderName = "X-Stella-CorrelationId"; + private const string LegacyCorrelationHeaderName = "X-Correlation-Id"; + private const string CorrelationItemKey = "__stella.correlationId"; + + public static IApplicationBuilder UseObservabilityHeaders(this IApplicationBuilder app) + { + return app.Use((context, next) => + { + var correlationId = ResolveCorrelationId(context); + context.Items[CorrelationItemKey] = correlationId; + + context.Response.OnStarting(state => + { + var httpContext = (HttpContext)state; + ApplyHeaders(httpContext); + return Task.CompletedTask; + }, context); + + return next(); + }); + } + + private static void ApplyHeaders(HttpContext context) + { + var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; + if (!string.IsNullOrWhiteSpace(traceId)) + { + context.Response.Headers[TraceHeaderName] = traceId; + } + + var correlationId = ResolveCorrelationId(context); + if (!string.IsNullOrWhiteSpace(correlationId)) + { + context.Response.Headers[CorrelationHeaderName] = correlationId!; + } + } + + private static string ResolveCorrelationId(HttpContext context) + { + if (context.Items.TryGetValue(CorrelationItemKey, out var existing) && existing is string cached && !string.IsNullOrWhiteSpace(cached)) + { + return cached; + } + + if (TryReadHeader(context.Request.Headers, CorrelationHeaderName, out var headerValue) || + TryReadHeader(context.Request.Headers, LegacyCorrelationHeaderName, out headerValue)) + { + return headerValue!; + } + + return context.TraceIdentifier; + } + + private static bool TryReadHeader(IHeaderDictionary headers, string name, out string? value) + { + if (headers.TryGetValue(name, out StringValues header) && !StringValues.IsNullOrEmpty(header)) + { + value = header.ToString(); + return true; + } + + value = null; + return false; + } +} diff --git a/src/Excititor/StellaOps.Excititor.WebService/Extensions/TelemetryExtensions.cs b/src/Excititor/StellaOps.Excititor.WebService/Extensions/TelemetryExtensions.cs new file mode 100644 index 000000000..7ee46dbf3 --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Extensions/TelemetryExtensions.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using StellaOps.Excititor.WebService.Options; +using StellaOps.Ingestion.Telemetry; + +namespace StellaOps.Excititor.WebService.Extensions; + +internal static class TelemetryExtensions +{ + public static void ConfigureExcititorTelemetry(this WebApplicationBuilder builder) + { + var telemetryOptions = new ExcititorTelemetryOptions(); + builder.Configuration.GetSection("Excititor:Telemetry").Bind(telemetryOptions); + + if (!telemetryOptions.Enabled || (!telemetryOptions.EnableTracing && !telemetryOptions.EnableMetrics)) + { + return; + } + + var openTelemetry = builder.Services.AddOpenTelemetry(); + + openTelemetry.ConfigureResource(resource => + { + var serviceName = telemetryOptions.ServiceName ?? builder.Environment.ApplicationName ?? "StellaOps.Excititor.WebService"; + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + resource.AddService(serviceName, serviceVersion: version, serviceInstanceId: Environment.MachineName); + + foreach (var attribute in telemetryOptions.ResourceAttributes) + { + if (string.IsNullOrWhiteSpace(attribute.Key) || string.IsNullOrWhiteSpace(attribute.Value)) + { + continue; + } + + resource.AddAttributes(new[] + { + new KeyValuePair(attribute.Key, attribute.Value) + }); + } + }); + + if (telemetryOptions.EnableTracing) + { + openTelemetry.WithTracing(tracing => + { + tracing + .AddSource(IngestionTelemetry.ActivitySourceName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + + ConfigureExporters(telemetryOptions, tracing); + }); + } + + if (telemetryOptions.EnableMetrics) + { + openTelemetry.WithMetrics(metrics => + { + metrics + .AddMeter(IngestionTelemetry.MeterName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + + ConfigureExporters(telemetryOptions, metrics); + }); + } + } + + private static void ConfigureExporters(ExcititorTelemetryOptions options, TracerProviderBuilder tracing) + { + if (!string.IsNullOrWhiteSpace(options.OtlpEndpoint)) + { + tracing.AddOtlpExporter(otlp => + { + otlp.Endpoint = new Uri(options.OtlpEndpoint, UriKind.Absolute); + var headers = BuildHeaders(options.OtlpHeaders); + if (!string.IsNullOrEmpty(headers)) + { + otlp.Headers = headers; + } + }); + return; + } + + if (options.ExportConsole) + { + tracing.AddConsoleExporter(); + } + } + + private static void ConfigureExporters(ExcititorTelemetryOptions options, MeterProviderBuilder metrics) + { + if (!string.IsNullOrWhiteSpace(options.OtlpEndpoint)) + { + metrics.AddOtlpExporter(otlp => + { + otlp.Endpoint = new Uri(options.OtlpEndpoint, UriKind.Absolute); + var headers = BuildHeaders(options.OtlpHeaders); + if (!string.IsNullOrEmpty(headers)) + { + otlp.Headers = headers; + } + }); + return; + } + + if (options.ExportConsole) + { + metrics.AddConsoleExporter(); + } + } + + private static string? BuildHeaders(IReadOnlyDictionary headers) + { + if (headers.Count == 0) + { + return null; + } + + var parts = new List(headers.Count); + foreach (var header in headers) + { + if (string.IsNullOrWhiteSpace(header.Key) || string.IsNullOrWhiteSpace(header.Value)) + { + continue; + } + + parts.Add($"{header.Key}={header.Value}"); + } + + return parts.Count == 0 ? null : string.Join(',', parts); + } +} diff --git a/src/Excititor/StellaOps.Excititor.WebService/Extensions/VexRawRequestMapper.cs b/src/Excititor/StellaOps.Excititor.WebService/Extensions/VexRawRequestMapper.cs new file mode 100644 index 000000000..3d94c14ab --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Extensions/VexRawRequestMapper.cs @@ -0,0 +1,150 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.WebService.Contracts; + +namespace StellaOps.Excititor.WebService.Extensions; + +internal static class VexRawRequestMapper +{ + public static VexRawDocument Map(VexIngestRequest request, string tenant, TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.ProviderId); + ArgumentNullException.ThrowIfNull(timeProvider); + + var source = request.Source ?? throw new ArgumentException("source section is required.", nameof(request)); + var upstream = request.Upstream ?? throw new ArgumentException("upstream section is required.", nameof(request)); + var content = request.Content ?? throw new ArgumentException("content section is required.", nameof(request)); + ArgumentException.ThrowIfNullOrWhiteSpace(upstream.SourceUri); + ArgumentException.ThrowIfNullOrWhiteSpace(upstream.UpstreamId); + + var providerId = request.ProviderId.Trim(); + var format = ParseFormat(content.Format); + var sourceUri = new Uri(upstream.SourceUri!, UriKind.Absolute); + var retrievedAt = upstream.RetrievedAt ?? timeProvider.GetUtcNow(); + var payload = SerializeContent(content.Raw); + var digest = NormalizeDigest(upstream.ContentHash, payload.Span); + + var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + CopyMetadata(metadataBuilder, request.Metadata); + CopyMetadata(metadataBuilder, upstream.Provenance); + + metadataBuilder["tenant"] = tenant.Trim().ToLowerInvariant(); + SetIfMissing(metadataBuilder, "source.vendor", source.Vendor); + SetIfMissing(metadataBuilder, "source.connector", source.Connector); + SetIfMissing(metadataBuilder, "source.connector_version", source.Version); + SetIfMissing(metadataBuilder, "source.stream", source.Stream ?? format.ToString().ToLowerInvariant()); + SetIfMissing(metadataBuilder, "upstream.id", upstream.UpstreamId); + SetIfMissing(metadataBuilder, "upstream.version", upstream.DocumentVersion ?? retrievedAt.ToString("O")); + SetIfMissing(metadataBuilder, "content.spec_version", content.SpecVersion); + SetIfMissing(metadataBuilder, "content.encoding", content.Encoding); + + var signature = upstream.Signature; + metadataBuilder["signature.present"] = (signature?.Present ?? false).ToString(); + SetIfMissing(metadataBuilder, "signature.format", signature?.Format); + SetIfMissing(metadataBuilder, "signature.key_id", signature?.KeyId); + SetIfMissing(metadataBuilder, "signature.sig", signature?.Signature); + SetIfMissing(metadataBuilder, "signature.certificate", signature?.Certificate); + SetIfMissing(metadataBuilder, "signature.digest", signature?.Digest); + + var metadata = metadataBuilder.ToImmutable(); + + return new VexRawDocument( + providerId, + format, + sourceUri, + retrievedAt, + digest, + payload, + metadata); + } + + private static ReadOnlyMemory SerializeContent(JsonElement element) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false })) + { + if (element.ValueKind == JsonValueKind.Undefined) + { + writer.WriteStartObject(); + writer.WriteEndObject(); + } + else + { + element.WriteTo(writer); + } + } + + return buffer.WrittenMemory.ToArray(); + } + + private static VexDocumentFormat ParseFormat(string format) + { + if (string.IsNullOrWhiteSpace(format)) + { + throw new ArgumentException("content.format is required.", nameof(format)); + } + + if (Enum.TryParse(format, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ArgumentException($"Unsupported VEX document format {format}.", nameof(format)); + } + + private static string NormalizeDigest(string? existingDigest, ReadOnlySpan payload) + { + if (!string.IsNullOrWhiteSpace(existingDigest)) + { + return existingDigest.Trim(); + } + + Span buffer = stackalloc byte[32]; + if (SHA256.TryHashData(payload, buffer, out _)) + { + return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant(); + } + + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(payload.ToArray()); + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static void CopyMetadata(ImmutableDictionary.Builder builder, IReadOnlyDictionary? values) + { + if (values is null) + { + return; + } + + foreach (var kvp in values) + { + if (string.IsNullOrWhiteSpace(kvp.Key)) + { + continue; + } + + builder[kvp.Key.Trim()] = kvp.Value?.Trim() ?? string.Empty; + } + } + + private static void SetIfMissing(ImmutableDictionary.Builder builder, string key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (!builder.ContainsKey(key)) + { + builder[key] = value.Trim(); + } + } +} diff --git a/src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorObservabilityOptions.cs b/src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorObservabilityOptions.cs new file mode 100644 index 000000000..40d9d3d9f --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorObservabilityOptions.cs @@ -0,0 +1,53 @@ +using System; + +namespace StellaOps.Excititor.WebService.Options; + +internal sealed class ExcititorObservabilityOptions +{ + public TimeSpan IngestWarningThreshold { get; set; } = TimeSpan.FromHours(6); + + public TimeSpan IngestCriticalThreshold { get; set; } = TimeSpan.FromHours(24); + + public TimeSpan LinkWarningThreshold { get; set; } = TimeSpan.FromMinutes(15); + + public TimeSpan LinkCriticalThreshold { get; set; } = TimeSpan.FromHours(1); + + public TimeSpan SignatureWindow { get; set; } = TimeSpan.FromHours(12); + + public double SignatureHealthyCoverage { get; set; } = 0.8; + + public double SignatureWarningCoverage { get; set; } = 0.5; + + public TimeSpan ConflictTrendWindow { get; set; } = TimeSpan.FromHours(24); + + public int ConflictTrendBucketMinutes { get; set; } = 60; + + public double ConflictWarningRatio { get; set; } = 0.15; + + public double ConflictCriticalRatio { get; set; } = 0.3; + + public int MaxConnectorDetails { get; set; } = 50; + + internal TimeSpan GetPositive(TimeSpan candidate, TimeSpan fallback) + => candidate <= TimeSpan.Zero ? fallback : candidate; + + internal double ClampRatio(double value, double fallback) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + return fallback; + } + + if (value < 0) + { + return 0; + } + + if (value > 1) + { + return 1; + } + + return value; + } +} diff --git a/src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorTelemetryOptions.cs b/src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorTelemetryOptions.cs new file mode 100644 index 000000000..8a0e77f89 --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Options/ExcititorTelemetryOptions.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Excititor.WebService.Options; + +internal sealed class ExcititorTelemetryOptions +{ + public bool Enabled { get; set; } = true; + + public bool EnableTracing { get; set; } = true; + + public bool EnableMetrics { get; set; } = true; + + public bool ExportConsole { get; set; } + + public string? ServiceName { get; set; } + + public string? OtlpEndpoint { get; set; } + + public Dictionary OtlpHeaders { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Dictionary ResourceAttributes { get; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Excititor/StellaOps.Excititor.WebService/Program.Helpers.cs b/src/Excititor/StellaOps.Excititor.WebService/Program.Helpers.cs new file mode 100644 index 000000000..0dc00f379 --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Program.Helpers.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Http; +using MongoDB.Bson; +using StellaOps.Excititor.Core.Aoc; +using StellaOps.Excititor.Storage.Mongo; +public partial class Program +{ + private const string TenantHeaderName = "X-Stella-Tenant"; + + private static bool TryResolveTenant(HttpContext context, VexMongoStorageOptions options, bool requireHeader, out string tenant, out IResult? problem) + { + tenant = options.DefaultTenant; + problem = null; + + if (context.Request.Headers.TryGetValue(TenantHeaderName, out var headerValues) && headerValues.Count > 0) + { + var requestedTenant = headerValues[0]?.Trim(); + if (string.IsNullOrEmpty(requestedTenant)) + { + problem = Results.Problem(detail: "X-Stella-Tenant header must not be empty.", statusCode: StatusCodes.Status400BadRequest, title: "Validation error"); + return false; + } + + if (!string.Equals(requestedTenant, options.DefaultTenant, StringComparison.OrdinalIgnoreCase)) + { + var detail = string.Format(CultureInfo.InvariantCulture, "Tenant '{0}' is not allowed for this Excititor deployment.", requestedTenant); + problem = Results.Problem(detail: detail, statusCode: StatusCodes.Status403Forbidden, title: "Forbidden"); + return false; + } + + tenant = requestedTenant; + return true; + } + + if (requireHeader) + { + var detail = string.Format(CultureInfo.InvariantCulture, "{0} header is required.", TenantHeaderName); + problem = Results.Problem(detail: detail, statusCode: StatusCodes.Status400BadRequest, title: "Validation error"); + return false; + } + + return true; + } + + private static IReadOnlyDictionary ReadMetadata(BsonValue value) + { + if (value is not BsonDocument doc || doc.ElementCount == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var element in doc.Elements) + { + if (string.IsNullOrWhiteSpace(element.Name)) + { + continue; + } + + result[element.Name] = element.Value?.ToString() ?? string.Empty; + } + + return result; + } + + private static bool TryDecodeCursor(string? cursor, out DateTimeOffset timestamp, out string digest) + { + timestamp = default; + digest = string.Empty; + if (string.IsNullOrWhiteSpace(cursor)) + { + return false; + } + + try + { + var payload = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); + var parts = payload.Split('|'); + if (parts.Length != 2) + { + return false; + } + + if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out timestamp)) + { + return false; + } + + digest = parts[1]; + return true; + } + catch + { + return false; + } + } + + private static string EncodeCursor(DateTime timestamp, string digest) + { + var payload = string.Format(CultureInfo.InvariantCulture, "{0:O}|{1}", timestamp, digest); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)); + } + + private static IResult ValidationProblem(string message) + => Results.Problem(detail: message, statusCode: StatusCodes.Status400BadRequest, title: "Validation error"); + + private static IResult MapGuardException(ExcititorAocGuardException exception) + { + var violations = exception.Violations.Select(violation => new + { + code = violation.ErrorCode, + path = violation.Path, + message = violation.Message + }); + + return Results.Problem( + detail: "VEX document failed Aggregation-Only Contract validation.", + statusCode: StatusCodes.Status400BadRequest, + title: "AOC violation", + extensions: new Dictionary + { + ["violations"] = violations.ToArray(), + ["primaryCode"] = exception.PrimaryErrorCode, + }); + } +} diff --git a/src/Excititor/StellaOps.Excititor.WebService/Program.cs b/src/Excititor/StellaOps.Excititor.WebService/Program.cs index 2860bca32..41cd312a0 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/Program.cs +++ b/src/Excititor/StellaOps.Excititor.WebService/Program.cs @@ -1,7 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Collections.Immutable; +using System.Globalization; +using System.Text; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using StellaOps.Excititor.Attestation.Verification; using StellaOps.Excititor.Attestation.Extensions; @@ -17,13 +20,17 @@ using StellaOps.Excititor.Formats.OpenVEX; using StellaOps.Excititor.Policy; using StellaOps.Excititor.Storage.Mongo; using StellaOps.Excititor.WebService.Endpoints; +using StellaOps.Excititor.WebService.Extensions; +using StellaOps.Excititor.WebService.Options; using StellaOps.Excititor.WebService.Services; using StellaOps.Excititor.Core.Aoc; +using StellaOps.Excititor.WebService.Contracts; +using MongoDB.Driver; +using MongoDB.Bson; -var builder = WebApplication.CreateBuilder(args); -var configuration = builder.Configuration; -var services = builder.Services; - +var builder = WebApplication.CreateBuilder(args); +var configuration = builder.Configuration; +var services = builder.Services; services.AddOptions() .Bind(configuration.GetSection("Excititor:Storage:Mongo")) .ValidateOnStart(); @@ -34,8 +41,11 @@ services.AddCycloneDxNormalizer(); services.AddOpenVexNormalizer(); services.AddSingleton(); services.AddScoped(); +services.AddOptions() + .Bind(configuration.GetSection("Excititor:Observability")); +services.AddScoped(); services.AddExcititorAocGuards(); -services.AddVexExportEngine(); +services.AddVexExportEngine(); services.AddVexExportCacheServices(); services.AddVexAttestation(); services.Configure(configuration.GetSection("Excititor:Attestation:Client")); @@ -85,14 +95,17 @@ if (offlineSection.Exists()) services.AddEndpointsApiExplorer(); services.AddHealthChecks(); services.AddSingleton(TimeProvider.System); -services.AddMemoryCache(); -services.AddAuthentication(); -services.AddAuthorization(); +services.AddMemoryCache(); +services.AddAuthentication(); +services.AddAuthorization(); + +builder.ConfigureExcititorTelemetry(); + +var app = builder.Build(); -var app = builder.Build(); - -app.UseAuthentication(); -app.UseAuthorization(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseObservabilityHeaders(); app.MapGet("/excititor/status", async (HttpContext context, IEnumerable artifactStores, @@ -143,26 +156,428 @@ app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async ( return Results.Ok(claims); }); -app.MapPost("/excititor/admin/backfill-statements", async ( - VexStatementBackfillRequest? request, - VexStatementBackfillService backfillService, - CancellationToken cancellationToken) => -{ +app.MapPost("/excititor/admin/backfill-statements", async ( + VexStatementBackfillRequest? request, + VexStatementBackfillService backfillService, + CancellationToken cancellationToken) => +{ request ??= new VexStatementBackfillRequest(); var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false); var message = FormattableString.Invariant( $"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}."); - return Results.Ok(new - { - message, - summary = result - }); -}); - -IngestEndpoints.MapIngestEndpoints(app); -ResolveEndpoint.MapResolveEndpoint(app); -MirrorEndpoints.MapMirrorEndpoints(app); + return Results.Ok(new + { + message, + summary = result + }); +}); + +app.MapPost("/ingest/vex", async ( + HttpContext context, + VexIngestRequest request, + IVexRawStore rawStore, + IOptions storageOptions, + TimeProvider timeProvider, + ILogger logger, + CancellationToken cancellationToken) => +{ + var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin"); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + VexRawDocument document; + try + { + document = VexRawRequestMapper.Map(request, tenant, timeProvider); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or FormatException) + { + return ValidationProblem(ex.Message); + } + + var existing = await rawStore.FindByDigestAsync(document.Digest, cancellationToken).ConfigureAwait(false); + try + { + await rawStore.StoreAsync(document, cancellationToken).ConfigureAwait(false); + } + catch (ExcititorAocGuardException guardException) + { + logger.LogWarning( + guardException, + "AOC guard rejected VEX ingest tenant={Tenant} digest={Digest}", + tenant, + document.Digest); + return MapGuardException(guardException); + } + + var inserted = existing is null; + if (inserted) + { + context.Response.Headers.Location = $"/vex/raw/{Uri.EscapeDataString(document.Digest)}"; + } + + var response = new VexIngestResponse(document.Digest, inserted, tenant, document.RetrievedAt); + return Results.Json(response, statusCode: inserted ? StatusCodes.Status201Created : StatusCodes.Status200OK); +}); + +app.MapGet("/vex/raw", async ( + HttpContext context, + IMongoDatabase database, + IOptions storageOptions, + CancellationToken cancellationToken) => +{ + var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError)) + { + return tenantError; + } + + var collection = database.GetCollection(VexMongoCollectionNames.Raw); + var query = context.Request.Query; + var filters = new List>(); + var builder = Builders.Filter; + + if (query.TryGetValue("providerId", out var providerValues)) + { + var providers = providerValues + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Trim()) + .ToArray(); + if (providers.Length > 0) + { + filters.Add(builder.In("ProviderId", providers)); + } + } + + if (query.TryGetValue("digest", out var digestValues)) + { + var digests = digestValues + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Trim()) + .ToArray(); + if (digests.Length > 0) + { + filters.Add(builder.In("Digest", digests)); + } + } + + if (query.TryGetValue("format", out var formatValues)) + { + var formats = formatValues + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Trim().ToLowerInvariant()) + .ToArray(); + if (formats.Length > 0) + { + filters.Add(builder.In("Format", formats)); + } + } + + if (query.TryGetValue("since", out var sinceValues) && DateTimeOffset.TryParse(sinceValues.FirstOrDefault(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var sinceValue)) + { + filters.Add(builder.Gte("RetrievedAt", sinceValue.UtcDateTime)); + } + + var cursorToken = query.TryGetValue("cursor", out var cursorValues) ? cursorValues.FirstOrDefault() : null; + DateTime? cursorTimestamp = null; + string? cursorDigest = null; + if (!string.IsNullOrWhiteSpace(cursorToken) && TryDecodeCursor(cursorToken, out var cursorTime, out var cursorId)) + { + cursorTimestamp = cursorTime.UtcDateTime; + cursorDigest = cursorId; + } + + if (cursorTimestamp is not null && cursorDigest is not null) + { + var ltTime = builder.Lt("RetrievedAt", cursorTimestamp.Value); + var eqTimeLtDigest = builder.And( + builder.Eq("RetrievedAt", cursorTimestamp.Value), + builder.Lt("Digest", cursorDigest)); + filters.Add(builder.Or(ltTime, eqTimeLtDigest)); + } + + var limit = 50; + if (query.TryGetValue("limit", out var limitValues) && int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var requestedLimit)) + { + limit = Math.Clamp(requestedLimit, 1, 200); + } + + var filter = filters.Count == 0 ? builder.Empty : builder.And(filters); + var sort = Builders.Sort.Descending("RetrievedAt").Descending("Digest"); + var documents = await collection + .Find(filter) + .Sort(sort) + .Limit(limit) + .Project(Builders.Projection.Include("Digest").Include("ProviderId").Include("Format").Include("SourceUri").Include("RetrievedAt").Include("Metadata").Include("GridFsObjectId")) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var summaries = new List(documents.Count); + foreach (var document in documents) + { + var digest = document.TryGetValue("Digest", out var digestValue) && digestValue.IsString ? digestValue.AsString : string.Empty; + var providerId = document.TryGetValue("ProviderId", out var providerValue) && providerValue.IsString ? providerValue.AsString : string.Empty; + var format = document.TryGetValue("Format", out var formatValue) && formatValue.IsString ? formatValue.AsString : string.Empty; + var sourceUri = document.TryGetValue("SourceUri", out var sourceValue) && sourceValue.IsString ? sourceValue.AsString : string.Empty; + var retrievedAt = document.TryGetValue("RetrievedAt", out var retrievedValue) && retrievedValue is BsonDateTime bsonDate + ? bsonDate.ToUniversalTime() + : DateTime.UtcNow; + var metadata = ReadMetadata(document.TryGetValue("Metadata", out var metadataValue) ? metadataValue : BsonNull.Value); + var inlineContent = !document.TryGetValue("GridFsObjectId", out var gridId) || gridId.IsBsonNull || (gridId.IsString && string.IsNullOrWhiteSpace(gridId.AsString)); + + summaries.Add(new VexRawSummaryResponse( + digest, + providerId, + format, + sourceUri, + new DateTimeOffset(retrievedAt), + inlineContent, + metadata)); + } + + var hasMore = documents.Count == limit; + string? nextCursor = null; + if (hasMore && documents.Count > 0) + { + var last = documents[^1]; + var lastTime = last.GetValue("RetrievedAt", BsonNull.Value).ToUniversalTime(); + var lastDigest = last.GetValue("Digest", BsonNull.Value).AsString; + nextCursor = EncodeCursor(lastTime, lastDigest); + } + + return Results.Json(new VexRawListResponse(summaries, nextCursor, hasMore)); +}); + +app.MapGet("/vex/raw/{digest}", async ( + string digest, + HttpContext context, + IVexRawStore rawStore, + IOptions storageOptions, + CancellationToken cancellationToken) => +{ + var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError)) + { + return tenantError; + } + + if (string.IsNullOrWhiteSpace(digest)) + { + return ValidationProblem("digest is required."); + } + + var record = await rawStore.FindByDigestAsync(digest.Trim(), cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + var rawDocument = VexRawDocumentMapper.ToRawModel(record, storageOptions.Value.DefaultTenant); + var response = new VexRawRecordResponse(record.Digest, rawDocument, record.RetrievedAt); + return Results.Json(response); +}); + +app.MapGet("/vex/raw/{digest}/provenance", async ( + string digest, + HttpContext context, + IVexRawStore rawStore, + IOptions storageOptions, + CancellationToken cancellationToken) => +{ + var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError)) + { + return tenantError; + } + + if (string.IsNullOrWhiteSpace(digest)) + { + return ValidationProblem("digest is required."); + } + + var record = await rawStore.FindByDigestAsync(digest.Trim(), cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + var rawDocument = VexRawDocumentMapper.ToRawModel(record, storageOptions.Value.DefaultTenant); + var response = new VexRawProvenanceResponse( + record.Digest, + rawDocument.Tenant, + rawDocument.Source, + rawDocument.Upstream, + record.RetrievedAt); + return Results.Json(response); +}); + +app.MapPost("/aoc/verify", async ( + HttpContext context, + VexAocVerifyRequest? request, + IMongoDatabase database, + IVexRawStore rawStore, + IVexRawWriteGuard guard, + IOptions storageOptions, + TimeProvider timeProvider, + CancellationToken cancellationToken) => +{ + var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin"); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var now = timeProvider.GetUtcNow(); + var since = (request?.Since ?? now.AddHours(-24)).UtcDateTime; + var until = (request?.Until ?? now).UtcDateTime; + if (since >= until) + { + since = until.AddHours(-1); + } + + var limit = Math.Clamp(request?.Limit ?? 100, 1, 500); + var sources = request?.Sources? + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Trim()) + .ToArray(); + var requestedCodes = request?.Codes? + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Trim()) + .ToArray(); + + var builder = Builders.Filter; + var filter = builder.And( + builder.Gte("RetrievedAt", since), + builder.Lte("RetrievedAt", until)); + + if (sources is { Length: > 0 }) + { + filter &= builder.In("ProviderId", sources); + } + + var collection = database.GetCollection(VexMongoCollectionNames.Raw); + var digests = await collection + .Find(filter) + .Sort(Builders.Sort.Descending("RetrievedAt")) + .Limit(limit) + .Project(Builders.Projection.Include("Digest").Include("RetrievedAt").Include("ProviderId")) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var checkedCount = 0; + var violationMap = new Dictionary Examples)>(StringComparer.OrdinalIgnoreCase); + const int MaxExamplesPerCode = 5; + + foreach (var digestDocument in digests) + { + var digestValue = digestDocument.GetValue("Digest", BsonNull.Value).AsString; + var provider = digestDocument.GetValue("ProviderId", BsonNull.Value).AsString; + + var domainDocument = await rawStore.FindByDigestAsync(digestValue, cancellationToken).ConfigureAwait(false); + if (domainDocument is null) + { + continue; + } + + var rawDocument = VexRawDocumentMapper.ToRawModel(domainDocument, storageOptions.Value.DefaultTenant); + try + { + guard.EnsureValid(rawDocument); + checkedCount++; + } + catch (ExcititorAocGuardException guardException) + { + checkedCount++; + foreach (var violation in guardException.Violations) + { + var code = violation.ErrorCode; + if (requestedCodes is { Length: > 0 } && !requestedCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + if (!violationMap.TryGetValue(code, out var aggregate)) + { + aggregate = (0, new List(MaxExamplesPerCode)); + } + + aggregate.Count++; + if (aggregate.Examples.Count < MaxExamplesPerCode) + { + aggregate.Examples.Add(new VexAocVerifyViolationExample( + provider, + digestValue, + rawDocument.Upstream.ContentHash, + violation.Path)); + } + + violationMap[code] = aggregate; + } + } + } + + var violations = violationMap + .Select(pair => new VexAocVerifyViolation(pair.Key, pair.Value.Count, pair.Value.Examples)) + .OrderByDescending(violation => violation.Count) + .ToList(); + + var response = new VexAocVerifyResponse( + tenant, + new VexAocVerifyWindow(new DateTimeOffset(since, TimeSpan.Zero), new DateTimeOffset(until, TimeSpan.Zero)), + new VexAocVerifyChecked(0, checkedCount), + violations, + new VexAocVerifyMetrics(checkedCount, violations.Sum(v => v.Count)), + digests.Count == limit); + + return Results.Json(response); +}); + +app.MapGet("/obs/excititor/health", async ( + HttpContext httpContext, + ExcititorHealthService healthService, + CancellationToken cancellationToken) => +{ + var scopeResult = ScopeAuthorization.RequireScope(httpContext, "vex.admin"); + if (scopeResult is not null) + { + return scopeResult; + } + + var payload = await healthService.GetAsync(cancellationToken).ConfigureAwait(false); + return Results.Ok(payload); +}); + +IngestEndpoints.MapIngestEndpoints(app); +ResolveEndpoint.MapResolveEndpoint(app); +MirrorEndpoints.MapMirrorEndpoints(app); app.Run(); diff --git a/src/Excititor/StellaOps.Excititor.WebService/Services/ExcititorHealthService.cs b/src/Excititor/StellaOps.Excititor.WebService/Services/ExcititorHealthService.cs new file mode 100644 index 000000000..90b1460bf --- /dev/null +++ b/src/Excititor/StellaOps.Excititor.WebService/Services/ExcititorHealthService.cs @@ -0,0 +1,667 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using MongoDB.Bson; +using MongoDB.Driver; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.Connectors.Abstractions; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.Storage.Mongo; +using StellaOps.Excititor.WebService.Options; + +namespace StellaOps.Excititor.WebService.Services; + +internal sealed class ExcititorHealthService +{ + private const string RetrievedAtField = "RetrievedAt"; + private const string MetadataField = "Metadata"; + private const string CalculatedAtField = "CalculatedAt"; + private const string ConflictsField = "Conflicts"; + private const string ConflictStatusField = "Status"; + + private readonly IMongoDatabase _database; + private readonly IVexProviderStore _providerStore; + private readonly IVexConnectorStateRepository _stateRepository; + private readonly IReadOnlyDictionary _connectors; + private readonly TimeProvider _timeProvider; + private readonly ExcititorObservabilityOptions _options; + private readonly ILogger _logger; + + public ExcititorHealthService( + IMongoDatabase database, + IVexProviderStore providerStore, + IVexConnectorStateRepository stateRepository, + IEnumerable connectors, + TimeProvider timeProvider, + IOptions options, + ILogger logger) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + _providerStore = providerStore ?? throw new ArgumentNullException(nameof(providerStore)); + _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); + _timeProvider = timeProvider ?? TimeProvider.System; + _options = options?.Value ?? new ExcititorObservabilityOptions(); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + if (connectors is null) + { + throw new ArgumentNullException(nameof(connectors)); + } + + _connectors = connectors + .GroupBy(connector => connector.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => DescribeConnector(group.First()), + StringComparer.OrdinalIgnoreCase); + } + + public async Task GetAsync(CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow(); + + var providersTask = _providerStore.ListAsync(cancellationToken).AsTask(); + var statesTask = _stateRepository.ListAsync(cancellationToken).AsTask(); + var signatureTask = LoadSignatureSnapshotAsync(now, cancellationToken); + var conflictTask = LoadConflictSnapshotAsync(now, cancellationToken); + var linkTask = LoadLinkSnapshotAsync(cancellationToken); + + await Task.WhenAll(providersTask, statesTask, signatureTask, conflictTask, linkTask).ConfigureAwait(false); + + var ingest = BuildIngestSection(now, providersTask.Result, statesTask.Result); + var link = BuildLinkSection(now, linkTask.Result); + var conflicts = BuildConflictSection(conflictTask.Result, link); + var signature = BuildSignatureSection(signatureTask.Result); + + return new ExcititorHealthDocument( + now, + ingest, + link, + signature, + conflicts); + } + + private IngestHealthSection BuildIngestSection( + DateTimeOffset now, + IReadOnlyCollection providers, + IReadOnlyCollection states) + { + var providerNames = providers + .GroupBy(provider => provider.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.First().DisplayName, + StringComparer.OrdinalIgnoreCase); + + var stateMap = states + .ToDictionary(state => state.ConnectorId, state => state, StringComparer.OrdinalIgnoreCase); + + var warningThreshold = _options.GetPositive(_options.IngestWarningThreshold, TimeSpan.FromHours(6)); + var criticalThreshold = _options.GetPositive(_options.IngestCriticalThreshold, TimeSpan.FromHours(24)); + + var connectorHealth = new List(_connectors.Count); + foreach (var descriptor in _connectors.Values.OrderBy(d => d.Id, StringComparer.OrdinalIgnoreCase)) + { + stateMap.TryGetValue(descriptor.Id, out var state); + var displayName = providerNames.TryGetValue(descriptor.Id, out var name) + ? name + : descriptor.DisplayName; + + var lastSuccess = state?.LastSuccessAt ?? state?.LastUpdated; + double? lagSeconds = null; + if (lastSuccess is not null) + { + var lag = now - lastSuccess.Value; + if (lag < TimeSpan.Zero) + { + lag = TimeSpan.Zero; + } + + lagSeconds = lag.TotalSeconds; + } + + var status = DetermineIngestStatus( + state?.FailureCount ?? 0, + lagSeconds, + warningThreshold.TotalSeconds, + criticalThreshold.TotalSeconds); + + connectorHealth.Add(new ConnectorHealth( + descriptor.Id, + displayName, + status, + lastSuccess, + state?.LastUpdated, + state?.NextEligibleRun, + lagSeconds, + state?.FailureCount ?? 0, + state?.LastFailureReason)); + } + + var overallStatus = ReduceStatuses(connectorHealth.Select(ch => ch.Status)); + double? maxLag = connectorHealth.Any(ch => ch.LagSeconds.HasValue) + ? connectorHealth.Max(ch => ch.LagSeconds ?? 0d) + : null; + + var maxDetails = _options.MaxConnectorDetails <= 0 ? 50 : _options.MaxConnectorDetails; + var projected = connectorHealth + .OrderByDescending(ch => SeverityRank(ch.Status)) + .ThenByDescending(ch => ch.LagSeconds ?? -1) + .ThenBy(ch => ch.ConnectorId, StringComparer.OrdinalIgnoreCase) + .Take(maxDetails) + .ToList(); + + return new IngestHealthSection(overallStatus, maxLag, projected); + } + + private LinkHealthSection BuildLinkSection(DateTimeOffset now, LinkSnapshot snapshot) + { + TimeSpan? lag = null; + if (snapshot.LastConsensusAt is { } calculatedAt) + { + lag = now - calculatedAt; + if (lag < TimeSpan.Zero) + { + lag = TimeSpan.Zero; + } + } + + var warning = _options.GetPositive(_options.LinkWarningThreshold, TimeSpan.FromMinutes(15)); + var critical = _options.GetPositive(_options.LinkCriticalThreshold, TimeSpan.FromHours(1)); + + var status = DetermineLagStatus(lag, warning, critical); + + return new LinkHealthSection( + status, + snapshot.LastConsensusAt, + lag?.TotalSeconds, + snapshot.TotalDocuments, + snapshot.DocumentsWithConflicts); + } + + private ConflictHealthSection BuildConflictSection(ConflictSnapshot snapshot, LinkHealthSection link) + { + var warningRatio = _options.ClampRatio(_options.ConflictWarningRatio, 0.15); + var criticalRatio = _options.ClampRatio(_options.ConflictCriticalRatio, 0.3); + + string status; + if (link.TotalDocuments <= 0) + { + status = "unknown"; + } + else + { + var ratio = (double)snapshot.DocumentsWithConflicts / link.TotalDocuments; + if (ratio >= criticalRatio) + { + status = "critical"; + } + else if (ratio >= warningRatio) + { + status = "warning"; + } + else + { + status = "healthy"; + } + } + + return new ConflictHealthSection( + status, + snapshot.WindowStart, + snapshot.WindowEnd, + snapshot.DocumentsWithConflicts, + snapshot.TotalConflicts, + snapshot.ByStatus, + snapshot.Trend); + } + + private SignatureHealthSection BuildSignatureSection(SignatureSnapshot snapshot) + { + if (snapshot.DocumentsEvaluated == 0) + { + return new SignatureHealthSection( + "unknown", + snapshot.WindowStart, + snapshot.WindowEnd, + 0, + 0, + 0, + 0, + 0, + 0); + } + + var coverage = snapshot.DocumentsEvaluated == 0 + ? 0d + : (double)snapshot.Verified / snapshot.DocumentsEvaluated; + + var healthy = _options.ClampRatio(_options.SignatureHealthyCoverage, 0.8); + var warning = _options.ClampRatio(_options.SignatureWarningCoverage, 0.5); + if (warning > healthy) + { + warning = healthy; + } + + var status = coverage switch + { + var value when value >= healthy => "healthy", + var value when value >= warning => "warning", + _ => "critical" + }; + + var failures = Math.Max(0, snapshot.WithSignatures - snapshot.Verified); + var unsigned = Math.Max(0, snapshot.DocumentsEvaluated - snapshot.WithSignatures); + + return new SignatureHealthSection( + status, + snapshot.WindowStart, + snapshot.WindowEnd, + snapshot.DocumentsEvaluated, + snapshot.WithSignatures, + snapshot.Verified, + failures, + unsigned, + coverage); + } + + private async Task LoadSignatureSnapshotAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var window = _options.GetPositive(_options.SignatureWindow, TimeSpan.FromHours(12)); + var windowStart = now - window; + + var collection = _database.GetCollection(VexMongoCollectionNames.Raw); + var filter = Builders.Filter.Gte(RetrievedAtField, windowStart.UtcDateTime); + var projection = Builders.Projection + .Include(MetadataField) + .Include(RetrievedAtField); + + List documents; + try + { + documents = await collection + .Find(filter) + .Project(projection) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load signature window metrics."); + documents = new List(); + } + + var evaluated = 0; + var withSignatures = 0; + var verified = 0; + + foreach (var document in documents) + { + evaluated++; + if (!document.TryGetValue(MetadataField, out var metadataValue) || + metadataValue is not BsonDocument metadata || + metadata.ElementCount == 0) + { + continue; + } + + if (TryGetBoolean(metadata, "signature.present", out var present) && present) + { + withSignatures++; + } + + if (TryGetBoolean(metadata, "signature.verified", out var verifiedFlag) && verifiedFlag) + { + verified++; + } + } + + return new SignatureSnapshot(windowStart, now, evaluated, withSignatures, verified); + } + + private async Task LoadLinkSnapshotAsync(CancellationToken cancellationToken) + { + var collection = _database.GetCollection(VexMongoCollectionNames.Consensus); + + BsonDocument? latest = null; + try + { + latest = await collection + .Find(Builders.Filter.Empty) + .Sort(Builders.Sort.Descending(CalculatedAtField)) + .Project(Builders.Projection.Include(CalculatedAtField)) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read latest consensus document."); + } + + DateTimeOffset? lastConsensusAt = null; + if (latest is not null && + latest.TryGetValue(CalculatedAtField, out var dateValue)) + { + var utc = TryReadDateTime(dateValue); + if (utc is not null) + { + lastConsensusAt = new DateTimeOffset(utc.Value, TimeSpan.Zero); + } + } + + long totalDocuments = 0; + long conflictDocuments = 0; + + try + { + totalDocuments = await collection.EstimatedDocumentCountAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + conflictDocuments = await collection.CountDocumentsAsync( + Builders.Filter.Exists($"{ConflictsField}.0"), + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to compute consensus counts."); + } + + return new LinkSnapshot(lastConsensusAt, totalDocuments, conflictDocuments); + } + + private async Task LoadConflictSnapshotAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var window = _options.GetPositive(_options.ConflictTrendWindow, TimeSpan.FromHours(24)); + var windowStart = now - window; + var collection = _database.GetCollection(VexMongoCollectionNames.Consensus); + + var filter = Builders.Filter.And( + Builders.Filter.Gte(CalculatedAtField, windowStart.UtcDateTime), + Builders.Filter.Exists($"{ConflictsField}.0")); + + var projection = Builders.Projection + .Include(CalculatedAtField) + .Include(ConflictsField); + + List documents; + try + { + documents = await collection + .Find(filter) + .Project(projection) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load conflict trend window."); + documents = new List(); + } + + var byStatus = new Dictionary(StringComparer.OrdinalIgnoreCase); + var timeline = new SortedDictionary(); + long totalConflicts = 0; + long docsWithConflicts = 0; + var bucketMinutes = Math.Max(1, _options.ConflictTrendBucketMinutes); + var bucketTicks = TimeSpan.FromMinutes(bucketMinutes).Ticks; + + foreach (var doc in documents) + { + if (!doc.TryGetValue(ConflictsField, out var conflictsValue) || + conflictsValue is not BsonArray conflicts || + conflicts.Count == 0) + { + continue; + } + + docsWithConflicts++; + totalConflicts += conflicts.Count; + + foreach (var conflictValue in conflicts.OfType()) + { + var status = conflictValue.TryGetValue(ConflictStatusField, out var statusValue) && statusValue.IsString + ? statusValue.AsString + : "unknown"; + + if (string.IsNullOrWhiteSpace(status)) + { + status = "unknown"; + } + + byStatus[status] = byStatus.TryGetValue(status, out var current) + ? current + 1 + : 1; + } + + if (doc.TryGetValue(CalculatedAtField, out var calculatedValue)) + { + var utc = TryReadDateTime(calculatedValue); + if (utc is null) + { + continue; + } + + var alignedTicks = AlignTicks(utc.Value, bucketTicks); + timeline[alignedTicks] = timeline.TryGetValue(alignedTicks, out var current) + ? current + conflicts.Count + : conflicts.Count; + } + } + + var trend = timeline + .Select(pair => new ConflictTrendPoint( + new DateTimeOffset(pair.Key, TimeSpan.Zero), + pair.Value)) + .ToList(); + + return new ConflictSnapshot( + windowStart, + now, + docsWithConflicts, + totalConflicts, + new Dictionary(byStatus, StringComparer.OrdinalIgnoreCase), + trend); + } + + private static string DetermineIngestStatus(int failureCount, double? lagSeconds, double warningSeconds, double criticalSeconds) + { + if (failureCount > 0) + { + return "failing"; + } + + if (lagSeconds is null) + { + return "unknown"; + } + + if (lagSeconds.Value >= criticalSeconds) + { + return "critical"; + } + + if (lagSeconds.Value >= warningSeconds) + { + return "warning"; + } + + return "healthy"; + } + + private static string DetermineLagStatus(TimeSpan? lag, TimeSpan warning, TimeSpan critical) + { + if (lag is null) + { + return "unknown"; + } + + if (lag.Value >= critical) + { + return "critical"; + } + + if (lag.Value >= warning) + { + return "warning"; + } + + return "healthy"; + } + + private static string ReduceStatuses(IEnumerable statuses) + { + var highest = "unknown"; + var highestRank = -1; + foreach (var status in statuses) + { + var rank = SeverityRank(status); + if (rank > highestRank) + { + highestRank = rank; + highest = status; + } + } + + return highest; + } + + private static int SeverityRank(string? status) + => status?.ToLowerInvariant() switch + { + "critical" => 4, + "failing" => 3, + "warning" => 2, + "unknown" => 1, + _ => 0, + }; + + private static long AlignTicks(DateTime dateTimeUtc, long bucketTicks) + { + var ticks = dateTimeUtc.Ticks; + return ticks - (ticks % bucketTicks); + } + + private static DateTime? TryReadDateTime(BsonValue value) + { + if (value is null) + { + return null; + } + + if (value.IsBsonDateTime) + { + return value.AsBsonDateTime.ToUniversalTime(); + } + + if (value.IsString && + DateTime.TryParse( + value.AsString, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var parsed)) + { + return DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + } + + return null; + } + + private static bool TryGetBoolean(BsonDocument document, string key, out bool value) + { + value = default; + if (!document.TryGetValue(key, out var bsonValue)) + { + return false; + } + + if (bsonValue.IsBoolean) + { + value = bsonValue.AsBoolean; + return true; + } + + if (bsonValue.IsString && bool.TryParse(bsonValue.AsString, out var parsed)) + { + value = parsed; + return true; + } + + return false; + } + + private static VexConnectorDescriptor DescribeConnector(IVexConnector connector) + => connector switch + { + VexConnectorBase baseConnector => baseConnector.Descriptor, + _ => new VexConnectorDescriptor(connector.Id, connector.Kind, connector.Id) + }; + + private sealed record LinkSnapshot(DateTimeOffset? LastConsensusAt, long TotalDocuments, long DocumentsWithConflicts); + + private sealed record ConflictSnapshot( + DateTimeOffset WindowStart, + DateTimeOffset WindowEnd, + long DocumentsWithConflicts, + long TotalConflicts, + IReadOnlyDictionary ByStatus, + IReadOnlyList Trend); + + private sealed record SignatureSnapshot( + DateTimeOffset WindowStart, + DateTimeOffset WindowEnd, + int DocumentsEvaluated, + int WithSignatures, + int Verified); +} + +internal sealed record ExcititorHealthDocument( + DateTimeOffset GeneratedAt, + IngestHealthSection Ingest, + LinkHealthSection Link, + SignatureHealthSection Signature, + ConflictHealthSection Conflicts); + +internal sealed record IngestHealthSection( + string Status, + double? MaxLagSeconds, + IReadOnlyList Connectors); + +internal sealed record ConnectorHealth( + string ConnectorId, + string DisplayName, + string Status, + DateTimeOffset? LastSuccessAt, + DateTimeOffset? LastUpdated, + DateTimeOffset? NextEligibleRun, + double? LagSeconds, + int FailureCount, + string? LastFailureReason); + +internal sealed record LinkHealthSection( + string Status, + DateTimeOffset? LastConsensusAt, + double? LagSeconds, + long TotalDocuments, + long DocumentsWithConflicts); + +internal sealed record SignatureHealthSection( + string Status, + DateTimeOffset WindowStart, + DateTimeOffset WindowEnd, + int DocumentsEvaluated, + int WithSignatures, + int Verified, + int Failures, + int Unsigned, + double Coverage); + +internal sealed record ConflictHealthSection( + string Status, + DateTimeOffset WindowStart, + DateTimeOffset WindowEnd, + long DocumentsWithConflicts, + long ConflictStatements, + IReadOnlyDictionary ByStatus, + IReadOnlyList Trend); + +internal sealed record ConflictTrendPoint(DateTimeOffset BucketStart, long Conflicts); diff --git a/src/Excititor/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs b/src/Excititor/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs index ede256a52..048c93e82 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs +++ b/src/Excititor/StellaOps.Excititor.WebService/Services/VexIngestOrchestrator.cs @@ -1,9 +1,11 @@ -using System.Collections.Immutable; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using Microsoft.Extensions.Logging; -using MongoDB.Driver; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Driver; using StellaOps.Excititor.Connectors.Abstractions; using StellaOps.Excititor.Core; using StellaOps.Excititor.Storage.Mongo; @@ -21,43 +23,50 @@ internal interface IVexIngestOrchestrator Task ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken); } -internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator -{ - private readonly IServiceProvider _serviceProvider; - private readonly IReadOnlyDictionary _connectors; - private readonly IVexRawStore _rawStore; - private readonly IVexClaimStore _claimStore; - private readonly IVexProviderStore _providerStore; - private readonly IVexConnectorStateRepository _stateRepository; - private readonly IVexNormalizerRouter _normalizerRouter; - private readonly IVexSignatureVerifier _signatureVerifier; - private readonly IVexMongoSessionProvider _sessionProvider; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; +internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator +{ + private readonly IServiceProvider _serviceProvider; + private readonly IReadOnlyDictionary _connectors; + private readonly IVexRawStore _rawStore; + private readonly IVexClaimStore _claimStore; + private readonly IVexProviderStore _providerStore; + private readonly IVexConnectorStateRepository _stateRepository; + private readonly IVexNormalizerRouter _normalizerRouter; + private readonly IVexSignatureVerifier _signatureVerifier; + private readonly IVexMongoSessionProvider _sessionProvider; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly string _defaultTenant; - public VexIngestOrchestrator( - IServiceProvider serviceProvider, - IEnumerable connectors, - IVexRawStore rawStore, - IVexClaimStore claimStore, - IVexProviderStore providerStore, - IVexConnectorStateRepository stateRepository, - IVexNormalizerRouter normalizerRouter, - IVexSignatureVerifier signatureVerifier, - IVexMongoSessionProvider sessionProvider, - TimeProvider timeProvider, - ILogger logger) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _rawStore = rawStore ?? throw new ArgumentNullException(nameof(rawStore)); - _claimStore = claimStore ?? throw new ArgumentNullException(nameof(claimStore)); + public VexIngestOrchestrator( + IServiceProvider serviceProvider, + IEnumerable connectors, + IVexRawStore rawStore, + IVexClaimStore claimStore, + IVexProviderStore providerStore, + IVexConnectorStateRepository stateRepository, + IVexNormalizerRouter normalizerRouter, + IVexSignatureVerifier signatureVerifier, + IVexMongoSessionProvider sessionProvider, + TimeProvider timeProvider, + IOptions storageOptions, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _rawStore = rawStore ?? throw new ArgumentNullException(nameof(rawStore)); + _claimStore = claimStore ?? throw new ArgumentNullException(nameof(claimStore)); _providerStore = providerStore ?? throw new ArgumentNullException(nameof(providerStore)); _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); _normalizerRouter = normalizerRouter ?? throw new ArgumentNullException(nameof(normalizerRouter)); _signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier)); - _sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider)); - _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + var optionsValue = (storageOptions ?? throw new ArgumentNullException(nameof(storageOptions))).Value + ?? throw new ArgumentNullException(nameof(storageOptions)); + _defaultTenant = string.IsNullOrWhiteSpace(optionsValue.DefaultTenant) + ? "default" + : optionsValue.DefaultTenant.Trim(); if (connectors is null) { @@ -149,7 +158,7 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator foreach (var handle in handles) { - var result = await ExecuteRunAsync(handle, since, options.Force, session, cancellationToken).ConfigureAwait(false); + var result = await ExecuteRunAsync(runId, handle, since, options.Force, session, cancellationToken).ConfigureAwait(false); results.Add(result); } @@ -172,12 +181,12 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator results.Add(ProviderRunResult.Missing(providerId, since: null)); } - foreach (var handle in handles) - { - var since = await ResolveResumeSinceAsync(handle.Descriptor.Id, options.Checkpoint, session, cancellationToken).ConfigureAwait(false); - var result = await ExecuteRunAsync(handle, since, force: false, session, cancellationToken).ConfigureAwait(false); - results.Add(result); - } + foreach (var handle in handles) + { + var since = await ResolveResumeSinceAsync(handle.Descriptor.Id, options.Checkpoint, session, cancellationToken).ConfigureAwait(false); + var result = await ExecuteRunAsync(runId, handle, since, force: false, session, cancellationToken).ConfigureAwait(false); + results.Add(result); + } var completedAt = _timeProvider.GetUtcNow(); return new IngestRunSummary(runId, startedAt, completedAt, results.ToImmutable()); @@ -210,8 +219,8 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator if (stale || state is null) { var since = stale ? threshold : lastUpdated; - var result = await ExecuteRunAsync(handle, since, force: false, session, cancellationToken).ConfigureAwait(false); - results.Add(new ReconcileProviderResult( + var result = await ExecuteRunAsync(runId, handle, since, force: false, session, cancellationToken).ConfigureAwait(false); + results.Add(new ReconcileProviderResult( handle.Descriptor.Id, result.Status, "reconciled", @@ -283,16 +292,25 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator await _providerStore.SaveAsync(provider, cancellationToken, session).ConfigureAwait(false); } - private async Task ExecuteRunAsync( - ConnectorHandle handle, - DateTimeOffset? since, - bool force, - IClientSessionHandle session, - CancellationToken cancellationToken) - { - var providerId = handle.Descriptor.Id; - var startedAt = _timeProvider.GetUtcNow(); - var stopwatch = Stopwatch.StartNew(); + private async Task ExecuteRunAsync( + Guid runId, + ConnectorHandle handle, + DateTimeOffset? since, + bool force, + IClientSessionHandle session, + CancellationToken cancellationToken) + { + var providerId = handle.Descriptor.Id; + var startedAt = _timeProvider.GetUtcNow(); + var stopwatch = Stopwatch.StartNew(); + using var scope = _logger.BeginScope(new Dictionary(StringComparer.Ordinal) + { + ["tenant"] = _defaultTenant, + ["runId"] = runId, + ["providerId"] = providerId, + ["window.since"] = since?.ToString("O", CultureInfo.InvariantCulture), + ["force"] = force, + }); try { diff --git a/src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj b/src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj index 6d30dbb11..ad355f714 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj +++ b/src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj @@ -7,6 +7,14 @@ enable true + + + + + + + + @@ -18,6 +26,7 @@ + - \ No newline at end of file + diff --git a/src/Excititor/StellaOps.Excititor.WebService/TASKS.md b/src/Excititor/StellaOps.Excititor.WebService/TASKS.md index 405baec66..3a4e306ad 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/TASKS.md +++ b/src/Excititor/StellaOps.Excititor.WebService/TASKS.md @@ -1,94 +1,95 @@ -# TASKS — Epic 1: Aggregation-Only Contract -> **AOC Reminder:** Excititor WebService publishes raw statements/linksets only; derived precedence/severity belongs to Policy overlays. -| ID | Status | Owner(s) | Depends on | Notes | -|---|---|---|---|---| -| EXCITITOR-WEB-AOC-19-001 `Raw VEX ingestion APIs` | TODO | Excititor WebService Guild | EXCITITOR-CORE-AOC-19-001, EXCITITOR-STORE-AOC-19-001 | Implement `POST /ingest/vex`, `GET /vex/raw*`, and `POST /aoc/verify` endpoints. Enforce Authority scopes, tenant injection, and guard pipeline to ensure only immutable VEX facts are persisted. | -> Docs alignment (2025-10-26): See AOC reference §4–5 and authority scopes doc for required tokens/behaviour. -| EXCITITOR-WEB-AOC-19-002 `AOC observability + metrics` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-WEB-AOC-19-001 | Export metrics (`ingestion_write_total`, `aoc_violation_total`, signature verification counters) and tracing spans matching Conseiller naming. Ensure structured logging includes tenant, source vendor, upstream id, and content hash. | -> Docs alignment (2025-10-26): Metrics/traces/log schema in `docs/observability/observability.md`. -| EXCITITOR-WEB-AOC-19-003 `Guard + schema test harness` | TODO | QA Guild | EXCITITOR-WEB-AOC-19-001 | Add unit/integration tests for schema validation, forbidden field rejection (`ERR_AOC_001/006/007`), and supersedes behavior using CycloneDX-VEX & CSAF fixtures with deterministic expectations. | -> Docs alignment (2025-10-26): Error codes + CLI verification in `docs/modules/cli/guides/cli-reference.md`. -| EXCITITOR-WEB-AOC-19-004 `Batch ingest validation` | TODO | Excititor WebService Guild, QA Guild | EXCITITOR-WEB-AOC-19-003, EXCITITOR-CORE-AOC-19-002 | Build large fixture ingest covering mixed VEX statuses, verifying raw storage parity, metrics, and CLI `aoc verify` compatibility. Document load test/runbook updates. | -> Docs alignment (2025-10-26): Offline/air-gap workflows captured in `docs/deploy/containers.md` §5. - -## Policy Engine v2 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-POLICY-20-001 `Policy selection endpoints` | TODO | Excititor WebService Guild | WEB-POLICY-20-001, EXCITITOR-CORE-AOC-19-004 | Provide VEX lookup APIs supporting PURL/advisory batching, scope filtering, and tenant enforcement with deterministic ordering + pagination. | - -## StellaOps Console (Sprint 23) - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-CONSOLE-23-001 `VEX aggregation views` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202 | Expose `/console/vex` endpoints returning grouped VEX statements per advisory/component with status chips, justification metadata, precedence trace pointers, and tenant-scoped filters for Console explorer. | -| EXCITITOR-CONSOLE-23-002 `Dashboard VEX deltas` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001, EXCITITOR-LNM-21-203 | Provide aggregated counts for VEX overrides (new, not_affected, revoked) powering Console dashboard + live status ticker; emit metrics for policy explain integration. | -| EXCITITOR-CONSOLE-23-003 `VEX search helpers` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001 | Deliver rapid lookup endpoints of VEX by advisory/component for Console global search; ensure response includes provenance and precedence context; include caching and RBAC. | - -## Graph Explorer v1 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| - -## Link-Not-Merge v1 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-LNM-21-201 `Observation APIs` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-001 | Add VEX observation read endpoints with filters, pagination, RBAC, and tenant scoping. | -| EXCITITOR-LNM-21-202 `Linkset APIs` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-002, EXCITITOR-LNM-21-003 | Implement linkset read/export/evidence endpoints returning correlation/conflict payloads and map errors to `ERR_AGG_*`. | -| EXCITITOR-LNM-21-203 `Event publishing` | TODO | Excititor WebService Guild, Platform Events Guild | EXCITITOR-LNM-21-005 | Publish `vex.linkset.updated` events, document schema, and ensure idempotent delivery. | - -## Graph & Vuln Explorer v1 - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-GRAPH-24-101 `VEX summary API` | TODO | Excititor WebService Guild | EXCITITOR-GRAPH-24-001 | Provide endpoints delivering VEX status summaries per component/asset for Vuln Explorer integration. | -| EXCITITOR-GRAPH-24-102 `Evidence batch API` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-201 | Add batch VEX observation retrieval optimized for Graph overlays/tooltips. | - -## VEX Lens (Sprint 30) - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-VEXLENS-30-001 `VEX evidence enrichers` | TODO | Excititor WebService Guild, VEX Lens Guild | EXCITITOR-VULN-29-001, VEXLENS-30-005 | Include issuer hints, signatures, and product trees in evidence payloads for VEX Lens; Label: VEX-Lens. | - -## Vulnerability Explorer (Sprint 29) - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-VULN-29-001 `VEX key canonicalization` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-001 | Canonicalize (lossless) VEX advisory/product keys (map to `advisory_key`, capture product scopes); expose original sources in `links[]`; AOC-compliant: no merge, no derived fields, no suppression; backfill existing records. | -| EXCITITOR-VULN-29-002 `Evidence retrieval` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001, VULN-API-29-003 | Provide `/vuln/evidence/vex/{advisory_key}` returning raw VEX statements filtered by tenant/product scope for Explorer evidence tabs. | -| EXCITITOR-VULN-29-004 `Observability` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-VULN-29-001 | Add metrics/logs for VEX normalization, suppression scopes, withdrawn statements; emit events consumed by Vuln Explorer resolver. | - -## Advisory AI (Sprint 31) - -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-AIAI-31-001 `Justification enrichment` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001 | Expose normalized VEX justifications, product trees, and paragraph anchors for Advisory AI conflict explanations. | -| EXCITITOR-AIAI-31-002 `VEX chunk API` | TODO | Excititor WebService Guild | EXCITITOR-AIAI-31-001, VEXLENS-30-006 | Provide `/vex/evidence/chunks` endpoint returning tenant-scoped VEX statements with signature metadata and scope scores for RAG. | -| EXCITITOR-AIAI-31-003 `Telemetry` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-AIAI-31-001 | Emit metrics/logs for VEX chunk usage, signature verification failures, and guardrail triggers. | - -## Observability & Forensics (Epic 15) -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-WEB-OBS-50-001 `Telemetry adoption` | TODO | Excititor WebService Guild | TELEMETRY-OBS-50-001, EXCITITOR-OBS-50-001 | Adopt telemetry core for VEX APIs, ensure responses include trace IDs & correlation headers, and update structured logging for read endpoints. | -| EXCITITOR-WEB-OBS-51-001 `Observability health endpoints` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, WEB-OBS-51-001 | Implement `/obs/excititor/health` summarizing ingest/link SLOs, signature failure counts, and conflict trends for Console dashboards. | -| EXCITITOR-WEB-OBS-52-001 `Timeline streaming` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, TIMELINE-OBS-52-003 | Provide SSE bridge for VEX timeline events with tenant filters, pagination, and guardrails. | -| EXCITITOR-WEB-OBS-53-001 `Evidence APIs` | TODO | Excititor WebService Guild, Evidence Locker Guild | EXCITITOR-OBS-53-001, EVID-OBS-53-003 | Expose `/evidence/vex/*` endpoints that fetch locker bundles, enforce scopes, and surface verification metadata. | -| EXCITITOR-WEB-OBS-54-001 `Attestation APIs` | TODO | Excititor WebService Guild | EXCITITOR-OBS-54-001, PROV-OBS-54-001 | Add `/attestations/vex/*` endpoints returning DSSE verification state, builder identity, and chain-of-custody links. | -| EXCITITOR-WEB-OBS-55-001 `Incident mode toggles` | TODO | Excititor WebService Guild, DevOps Guild | EXCITITOR-OBS-55-001, WEB-OBS-55-001 | Provide incident mode API for VEX pipelines with activation audit logs and retention override previews. | - -## Air-Gapped Mode (Epic 16) -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-WEB-AIRGAP-56-001 | TODO | Excititor WebService Guild | AIRGAP-IMP-58-001, EXCITITOR-AIRGAP-56-001 | Support mirror bundle registration via APIs, expose bundle provenance in VEX responses, and block external connectors in sealed mode. | -| EXCITITOR-WEB-AIRGAP-56-002 | TODO | Excititor WebService Guild, AirGap Time Guild | EXCITITOR-WEB-AIRGAP-56-001, AIRGAP-TIME-58-001 | Return VEX staleness metrics and time anchor info in API responses for Console/CLI use. | -| EXCITITOR-WEB-AIRGAP-57-001 | TODO | Excititor WebService Guild, AirGap Policy Guild | AIRGAP-POL-56-001 | Map sealed-mode violations to standardized error payload with remediation guidance. | -| EXCITITOR-WEB-AIRGAP-58-001 | TODO | Excititor WebService Guild, AirGap Importer Guild | EXCITITOR-WEB-AIRGAP-56-001, TIMELINE-OBS-53-001 | Emit timeline events for VEX bundle imports with bundle ID, scope, and actor metadata. | - -## SDKs & OpenAPI (Epic 17) -| ID | Status | Owner(s) | Depends on | Notes | -|----|--------|----------|------------|-------| -| EXCITITOR-WEB-OAS-61-001 | TODO | Excititor WebService Guild | OAS-61-001 | Implement `/.well-known/openapi` discovery endpoint with spec version metadata. | -| EXCITITOR-WEB-OAS-61-002 | TODO | Excititor WebService Guild | APIGOV-61-001 | Standardize error envelope responses and update controller/unit tests. | -| EXCITITOR-WEB-OAS-62-001 | TODO | Excititor WebService Guild | EXCITITOR-OAS-61-002 | Add curated examples for VEX observation/linkset endpoints and ensure portal displays them. | -| EXCITITOR-WEB-OAS-63-001 | TODO | Excititor WebService Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and update docs for retiring VEX APIs. | +# TASKS — Epic 1: Aggregation-Only Contract +> **AOC Reminder:** Excititor WebService publishes raw statements/linksets only; derived precedence/severity belongs to Policy overlays. +| ID | Status | Owner(s) | Depends on | Notes | +|---|---|---|---|---| +| EXCITITOR-WEB-AOC-19-001 `Raw VEX ingestion APIs` | DONE (2025-11-08) | Excititor WebService Guild | EXCITITOR-CORE-AOC-19-001, EXCITITOR-STORE-AOC-19-001 | Implement `POST /ingest/vex`, `GET /vex/raw*`, and `POST /aoc/verify` endpoints. Enforce Authority scopes, tenant injection, and guard pipeline to ensure only immutable VEX facts are persisted. | +> Docs alignment (2025-10-26): See AOC reference §4–5 and authority scopes doc for required tokens/behaviour. +| EXCITITOR-WEB-AOC-19-002 `AOC observability + metrics` | DONE (2025-11-08) | Excititor WebService Guild, Observability Guild | EXCITITOR-WEB-AOC-19-001 | Export metrics (`ingestion_write_total`, `aoc_violation_total`, signature verification counters) and tracing spans matching Conseiller naming. Ensure structured logging includes tenant, source vendor, upstream id, and content hash. | +> Docs alignment (2025-10-26): Metrics/traces/log schema in `docs/observability/observability.md`. +| EXCITITOR-WEB-AOC-19-003 `Guard + schema test harness` | DONE (2025-11-08) | QA Guild | EXCITITOR-WEB-AOC-19-001 | Add unit/integration tests for schema validation, forbidden field rejection (`ERR_AOC_001/006/007`), and supersedes behavior using CycloneDX-VEX & CSAF fixtures with deterministic expectations. | +> Docs alignment (2025-10-26): Error codes + CLI verification in `docs/modules/cli/guides/cli-reference.md`. +| EXCITITOR-WEB-AOC-19-004 `Batch ingest validation` | DONE (2025-11-08) | Excititor WebService Guild, QA Guild | EXCITITOR-WEB-AOC-19-003, EXCITITOR-CORE-AOC-19-002 | Build large fixture ingest covering mixed VEX statuses, verifying raw storage parity, metrics, and CLI `aoc verify` compatibility. Document load test/runbook updates. | +> Docs alignment (2025-10-26): Offline/air-gap workflows captured in `docs/deploy/containers.md` §5. +| EXCITITOR-CRYPTO-90-001 `Crypto provider adoption` | TODO | Excititor WebService Guild, Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Replace direct `System.Security.Cryptography` hashing/signing inside connector loaders, VEX exporters, and OpenAPI discovery with `ICryptoProviderRegistry` + `ICryptoHash`. Reference `docs/security/crypto-routing-audit-2025-11-07.md`. | Registry-backed providers configurable per deployment; integration tests cover default + `ru-offline` profiles; connectors honor sovereign provider ordering. | + +## Policy Engine v2 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-POLICY-20-001 `Policy selection endpoints` | TODO | Excititor WebService Guild | WEB-POLICY-20-001, EXCITITOR-CORE-AOC-19-004 | Provide VEX lookup APIs supporting PURL/advisory batching, scope filtering, and tenant enforcement with deterministic ordering + pagination. | + +## StellaOps Console (Sprint 23) + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-CONSOLE-23-001 `VEX aggregation views` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202 | Expose `/console/vex` endpoints returning grouped VEX statements per advisory/component with status chips, justification metadata, precedence trace pointers, and tenant-scoped filters for Console explorer. | +| EXCITITOR-CONSOLE-23-002 `Dashboard VEX deltas` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001, EXCITITOR-LNM-21-203 | Provide aggregated counts for VEX overrides (new, not_affected, revoked) powering Console dashboard + live status ticker; emit metrics for policy explain integration. | +| EXCITITOR-CONSOLE-23-003 `VEX search helpers` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001 | Deliver rapid lookup endpoints of VEX by advisory/component for Console global search; ensure response includes provenance and precedence context; include caching and RBAC. | + +## Graph Explorer v1 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| + +## Link-Not-Merge v1 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-LNM-21-201 `Observation APIs` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-001 | Add VEX observation read endpoints with filters, pagination, RBAC, and tenant scoping. | +| EXCITITOR-LNM-21-202 `Linkset APIs` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-002, EXCITITOR-LNM-21-003 | Implement linkset read/export/evidence endpoints returning correlation/conflict payloads and map errors to `ERR_AGG_*`. | +| EXCITITOR-LNM-21-203 `Event publishing` | TODO | Excititor WebService Guild, Platform Events Guild | EXCITITOR-LNM-21-005 | Publish `vex.linkset.updated` events, document schema, and ensure idempotent delivery. | + +## Graph & Vuln Explorer v1 + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-GRAPH-24-101 `VEX summary API` | TODO | Excititor WebService Guild | EXCITITOR-GRAPH-24-001 | Provide endpoints delivering VEX status summaries per component/asset for Vuln Explorer integration. | +| EXCITITOR-GRAPH-24-102 `Evidence batch API` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-201 | Add batch VEX observation retrieval optimized for Graph overlays/tooltips. | + +## VEX Lens (Sprint 30) + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-VEXLENS-30-001 `VEX evidence enrichers` | TODO | Excititor WebService Guild, VEX Lens Guild | EXCITITOR-VULN-29-001, VEXLENS-30-005 | Include issuer hints, signatures, and product trees in evidence payloads for VEX Lens; Label: VEX-Lens. | + +## Vulnerability Explorer (Sprint 29) + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-VULN-29-001 `VEX key canonicalization` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-001 | Canonicalize (lossless) VEX advisory/product keys (map to `advisory_key`, capture product scopes); expose original sources in `links[]`; AOC-compliant: no merge, no derived fields, no suppression; backfill existing records. | +| EXCITITOR-VULN-29-002 `Evidence retrieval` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001, VULN-API-29-003 | Provide `/vuln/evidence/vex/{advisory_key}` returning raw VEX statements filtered by tenant/product scope for Explorer evidence tabs. | +| EXCITITOR-VULN-29-004 `Observability` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-VULN-29-001 | Add metrics/logs for VEX normalization, suppression scopes, withdrawn statements; emit events consumed by Vuln Explorer resolver. | + +## Advisory AI (Sprint 31) + +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-AIAI-31-001 `Justification enrichment` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001 | Expose normalized VEX justifications, product trees, and paragraph anchors for Advisory AI conflict explanations. | +| EXCITITOR-AIAI-31-002 `VEX chunk API` | TODO | Excititor WebService Guild | EXCITITOR-AIAI-31-001, VEXLENS-30-006 | Provide `/vex/evidence/chunks` endpoint returning tenant-scoped VEX statements with signature metadata and scope scores for RAG. | +| EXCITITOR-AIAI-31-003 `Telemetry` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-AIAI-31-001 | Emit metrics/logs for VEX chunk usage, signature verification failures, and guardrail triggers. | + +## Observability & Forensics (Epic 15) +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-WEB-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Excititor WebService Guild | TELEMETRY-OBS-50-001, EXCITITOR-OBS-50-001 | Adopt telemetry core for VEX APIs, ensure responses include trace IDs & correlation headers, and update structured logging for read endpoints. | +| EXCITITOR-WEB-OBS-51-001 `Observability health endpoints` | DONE (2025-11-08) | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, WEB-OBS-51-001 | Implement `/obs/excititor/health` summarizing ingest/link SLOs, signature failure counts, and conflict trends for Console dashboards. | +| EXCITITOR-WEB-OBS-52-001 `Timeline streaming` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, TIMELINE-OBS-52-003 | Provide SSE bridge for VEX timeline events with tenant filters, pagination, and guardrails. | +| EXCITITOR-WEB-OBS-53-001 `Evidence APIs` | TODO | Excititor WebService Guild, Evidence Locker Guild | EXCITITOR-OBS-53-001, EVID-OBS-53-003 | Expose `/evidence/vex/*` endpoints that fetch locker bundles, enforce scopes, and surface verification metadata. | +| EXCITITOR-WEB-OBS-54-001 `Attestation APIs` | TODO | Excititor WebService Guild | EXCITITOR-OBS-54-001, PROV-OBS-54-001 | Add `/attestations/vex/*` endpoints returning DSSE verification state, builder identity, and chain-of-custody links. | +| EXCITITOR-WEB-OBS-55-001 `Incident mode toggles` | TODO | Excititor WebService Guild, DevOps Guild | EXCITITOR-OBS-55-001, WEB-OBS-55-001 | Provide incident mode API for VEX pipelines with activation audit logs and retention override previews. | + +## Air-Gapped Mode (Epic 16) +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-WEB-AIRGAP-56-001 | TODO | Excititor WebService Guild | AIRGAP-IMP-58-001, EXCITITOR-AIRGAP-56-001 | Support mirror bundle registration via APIs, expose bundle provenance in VEX responses, and block external connectors in sealed mode. | +| EXCITITOR-WEB-AIRGAP-56-002 | TODO | Excititor WebService Guild, AirGap Time Guild | EXCITITOR-WEB-AIRGAP-56-001, AIRGAP-TIME-58-001 | Return VEX staleness metrics and time anchor info in API responses for Console/CLI use. | +| EXCITITOR-WEB-AIRGAP-57-001 | TODO | Excititor WebService Guild, AirGap Policy Guild | AIRGAP-POL-56-001 | Map sealed-mode violations to standardized error payload with remediation guidance. | +| EXCITITOR-WEB-AIRGAP-58-001 | TODO | Excititor WebService Guild, AirGap Importer Guild | EXCITITOR-WEB-AIRGAP-56-001, TIMELINE-OBS-53-001 | Emit timeline events for VEX bundle imports with bundle ID, scope, and actor metadata. | + +## SDKs & OpenAPI (Epic 17) +| ID | Status | Owner(s) | Depends on | Notes | +|----|--------|----------|------------|-------| +| EXCITITOR-WEB-OAS-61-001 | TODO | Excititor WebService Guild | OAS-61-001 | Implement `/.well-known/openapi` discovery endpoint with spec version metadata. | +| EXCITITOR-WEB-OAS-61-002 | TODO | Excititor WebService Guild | APIGOV-61-001 | Standardize error envelope responses and update controller/unit tests. | +| EXCITITOR-WEB-OAS-62-001 | TODO | Excititor WebService Guild | EXCITITOR-OAS-61-002 | Add curated examples for VEX observation/linkset endpoints and ensure portal displays them. | +| EXCITITOR-WEB-OAS-63-001 | TODO | Excititor WebService Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and update docs for retiring VEX APIs. | diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Core/Aoc/VexRawWriteGuard.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Core/Aoc/VexRawWriteGuard.cs index 13187a195..53053dd54 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Core/Aoc/VexRawWriteGuard.cs +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Core/Aoc/VexRawWriteGuard.cs @@ -1,7 +1,9 @@ -using System.Text.Json; -using Microsoft.Extensions.Options; -using StellaOps.Aoc; -using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Options; +using StellaOps.Aoc; +using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument; +using StellaOps.Ingestion.Telemetry; namespace StellaOps.Excititor.Core.Aoc; @@ -21,15 +23,35 @@ public sealed class VexRawWriteGuard : IVexRawWriteGuard _options = options?.Value ?? AocGuardOptions.Default; } - public void EnsureValid(RawVexDocument document) - { - ArgumentNullException.ThrowIfNull(document); - - using var payload = JsonDocument.Parse(JsonSerializer.Serialize(document, SerializerOptions)); - var result = _guard.Validate(payload.RootElement, _options); - if (!result.IsValid) - { - throw new ExcititorAocGuardException(result); - } - } -} + public void EnsureValid(RawVexDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + using var guardActivity = IngestionTelemetry.StartGuardActivity( + document.Tenant, + document.Source.Vendor, + document.Upstream.UpstreamId, + document.Upstream.ContentHash, + document.Supersedes); + + using var payload = JsonDocument.Parse(JsonSerializer.Serialize(document, SerializerOptions)); + var result = _guard.Validate(payload.RootElement, _options); + if (!result.IsValid) + { + var violationCount = result.Violations.IsDefaultOrEmpty ? 0 : result.Violations.Length; + var primaryCode = violationCount > 0 ? result.Violations[0].ErrorCode : string.Empty; + + guardActivity?.SetTag("violationCount", violationCount); + if (!string.IsNullOrWhiteSpace(primaryCode)) + { + guardActivity?.SetTag("code", primaryCode); + } + + guardActivity?.SetStatus(ActivityStatusCode.Error, primaryCode); + throw new ExcititorAocGuardException(result); + } + + guardActivity?.SetTag("violationCount", 0); + guardActivity?.SetStatus(ActivityStatusCode.Ok); + } +} diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj b/src/Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj index d6bc7e3ad..f83e82b1f 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj @@ -13,5 +13,6 @@ + diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md b/src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md index c7699cf38..d954291ff 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Core/TASKS.md @@ -62,7 +62,7 @@ ## Observability & Forensics (Epic 15) | ID | Status | Owner(s) | Depends on | Notes | |----|--------|----------|------------|-------| -| EXCITITOR-OBS-50-001 `Telemetry adoption` | TODO | Excititor Core Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Integrate telemetry core across VEX ingestion/linking, ensuring spans/logs capture tenant, product scope, upstream id, justification hash, and trace IDs. | +| EXCITITOR-OBS-50-001 `Telemetry adoption` | DONE (2025-11-07) | Excititor Core Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Integrate telemetry core across VEX ingestion/linking, ensuring spans/logs capture tenant, product scope, upstream id, justification hash, and trace IDs. | | EXCITITOR-OBS-51-001 `Metrics & SLOs` | TODO | Excititor Core Guild, DevOps Guild | EXCITITOR-OBS-50-001, TELEMETRY-OBS-51-001 | Publish metrics for VEX ingest latency, scope resolution success, conflict rate, signature verification failures. Define SLOs (link latency P95 <30s) and configure burn-rate alerts. | | EXCITITOR-OBS-52-001 `Timeline events` | TODO | Excititor Core Guild | EXCITITOR-OBS-50-001, TIMELINE-OBS-52-002 | Emit `timeline_event` entries for VEX ingest/linking/outcome changes with trace IDs, justification summaries, and evidence placeholders. | | EXCITITOR-OBS-53-001 `Evidence snapshots` | TODO | Excititor Core Guild, Evidence Locker Guild | EXCITITOR-OBS-52-001, EVID-OBS-53-002 | Build evidence payloads for VEX statements (raw doc, normalization diff, precedence notes) and push to evidence locker with Merkle manifests. | diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/IVexStorageContracts.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/IVexStorageContracts.cs index 7309254a0..5eb564c17 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/IVexStorageContracts.cs +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/IVexStorageContracts.cs @@ -68,6 +68,8 @@ public interface IVexConnectorStateRepository ValueTask GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null); ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null); + + ValueTask> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null); } public interface IVexConsensusHoldStore diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexConnectorStateRepository.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexConnectorStateRepository.cs index e8f7de4b7..6104a3097 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexConnectorStateRepository.cs +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexConnectorStateRepository.cs @@ -1,9 +1,10 @@ using System; -using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MongoDB.Driver; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver; namespace StellaOps.Excititor.Storage.Mongo; @@ -29,11 +30,11 @@ public sealed class MongoVexConnectorStateRepository : IVexConnectorStateReposit return document?.ToRecord(); } - public async ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - ArgumentNullException.ThrowIfNull(state); - - var document = VexConnectorStateDocument.FromRecord(state.WithNormalizedDigests()); + public async ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + ArgumentNullException.ThrowIfNull(state); + + var document = VexConnectorStateDocument.FromRecord(state.WithNormalizedDigests()); var filter = Builders.Filter.Eq(x => x.ConnectorId, document.ConnectorId); if (session is null) { @@ -41,10 +42,24 @@ public sealed class MongoVexConnectorStateRepository : IVexConnectorStateReposit } else { - await _collection.ReplaceOneAsync(session, filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); - } - } -} + await _collection.ReplaceOneAsync(session, filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var find = session is null + ? _collection.Find(FilterDefinition.Empty) + : _collection.Find(session, FilterDefinition.Empty); + + var documents = await find + .SortBy(x => x.ConnectorId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return documents.ConvertAll(static document => document.ToRecord()); + } +} internal static class VexConnectorStateExtensions { diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexRawStore.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexRawStore.cs index 2db6ea02d..54a40c1eb 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexRawStore.cs +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/MongoVexRawStore.cs @@ -1,36 +1,34 @@ using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MongoDB.Bson; using MongoDB.Driver; -using MongoDB.Driver.Core.Clusters; +using MongoDB.Driver.Core.Clusters; using MongoDB.Driver.GridFS; using StellaOps.Excititor.Core; using StellaOps.Excititor.Core.Aoc; -using RawContentMetadata = StellaOps.Concelier.RawModels.RawContent; -using RawLinkset = StellaOps.Concelier.RawModels.RawLinkset; -using RawReference = StellaOps.Concelier.RawModels.RawReference; -using RawSignatureMetadata = StellaOps.Concelier.RawModels.RawSignatureMetadata; -using RawSourceMetadata = StellaOps.Concelier.RawModels.RawSourceMetadata; -using RawUpstreamMetadata = StellaOps.Concelier.RawModels.RawUpstreamMetadata; -using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument; -using VexStatementSummaryModel = StellaOps.Concelier.RawModels.VexStatementSummary; +using StellaOps.Ingestion.Telemetry; namespace StellaOps.Excititor.Storage.Mongo; public sealed class MongoVexRawStore : IVexRawStore { - private readonly IMongoClient _client; + private readonly IMongoClient _client; private readonly IMongoCollection _collection; private readonly GridFSBucket _bucket; private readonly VexMongoStorageOptions _options; private readonly IVexMongoSessionProvider _sessionProvider; private readonly IVexRawWriteGuard _guard; + private readonly ILogger _logger; private readonly string _connectorVersion; public MongoVexRawStore( @@ -38,13 +36,15 @@ public sealed class MongoVexRawStore : IVexRawStore IMongoDatabase database, IOptions options, IVexMongoSessionProvider sessionProvider, - IVexRawWriteGuard guard) + IVexRawWriteGuard guard, + ILogger? logger = null) { _client = client ?? throw new ArgumentNullException(nameof(client)); ArgumentNullException.ThrowIfNull(database); ArgumentNullException.ThrowIfNull(options); _sessionProvider = sessionProvider ?? throw new ArgumentNullException(nameof(sessionProvider)); _guard = guard ?? throw new ArgumentNullException(nameof(guard)); + _logger = logger ?? NullLogger.Instance; _options = options.Value; Validator.ValidateObject(_options, new ValidationContext(_options), validateAllProperties: true); @@ -54,350 +54,285 @@ public sealed class MongoVexRawStore : IVexRawStore _collection = database.GetCollection(VexMongoCollectionNames.Raw); _bucket = new GridFSBucket(database, new GridFSBucketOptions { - BucketName = _options.RawBucketName, - ReadConcern = database.Settings.ReadConcern, - ReadPreference = database.Settings.ReadPreference, - WriteConcern = database.Settings.WriteConcern, - }); - } - + BucketName = _options.RawBucketName, + ReadConcern = database.Settings.ReadConcern, + ReadPreference = database.Settings.ReadPreference, + WriteConcern = database.Settings.WriteConcern, + }); + } + public async ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) { ArgumentNullException.ThrowIfNull(document); - var guardPayload = CreateRawModel(document); - _guard.EnsureValid(guardPayload); + var guardPayload = VexRawDocumentMapper.ToRawModel(document, _options.DefaultTenant); + var tenant = guardPayload.Tenant; + var sourceVendor = guardPayload.Source.Vendor; + var upstreamId = guardPayload.Upstream.UpstreamId; + var contentHash = guardPayload.Upstream.ContentHash; + + using var logScope = _logger.BeginScope(new Dictionary(StringComparer.Ordinal) + { + ["tenant"] = tenant, + ["source.vendor"] = sourceVendor, + ["upstream.upstreamId"] = upstreamId, + ["contentHash"] = contentHash, + ["providerId"] = document.ProviderId, + ["digest"] = document.Digest, + }); + + var transformWatch = Stopwatch.StartNew(); + using var transformActivity = IngestionTelemetry.StartTransformActivity( + tenant, + sourceVendor, + upstreamId, + contentHash, + document.Format.ToString(), + document.Content.Length); + + try + { + _guard.EnsureValid(guardPayload); + transformActivity?.SetStatus(ActivityStatusCode.Ok); + } + catch (ExcititorAocGuardException ex) + { + transformActivity?.SetTag("violationCount", ex.Violations.IsDefaultOrEmpty ? 0 : ex.Violations.Length); + transformActivity?.SetTag("code", ex.PrimaryErrorCode); + transformActivity?.SetStatus(ActivityStatusCode.Error, ex.PrimaryErrorCode); + + IngestionTelemetry.RecordViolation(tenant, sourceVendor, ex.PrimaryErrorCode); + IngestionTelemetry.RecordWriteAttempt(tenant, sourceVendor, IngestionTelemetry.ResultReject); + + _logger.LogWarning(ex, "AOC guard rejected VEX document digest={Digest} provider={ProviderId}", document.Digest, document.ProviderId); + throw; + } + finally + { + if (transformWatch.IsRunning) + { + transformWatch.Stop(); + } + + IngestionTelemetry.RecordLatency(tenant, sourceVendor, IngestionTelemetry.PhaseTransform, transformWatch.Elapsed); + } var threshold = _options.GridFsInlineThresholdBytes; var useInline = threshold == 0 || document.Content.Length <= threshold; string? newGridId = null; string? oldGridIdToDelete = null; - - var sessionHandle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false); - - if (!useInline) - { - newGridId = await UploadToGridFsAsync(document, sessionHandle, cancellationToken).ConfigureAwait(false); - } - - var supportsTransactions = sessionHandle.Client.Cluster.Description.Type != ClusterType.Standalone - && !sessionHandle.IsInTransaction; - - var startedTransaction = false; - if (supportsTransactions) - { - try - { - sessionHandle.StartTransaction(); - startedTransaction = true; - } - catch (NotSupportedException) - { - supportsTransactions = false; - } - } - - try - { - var filter = Builders.Filter.Eq(x => x.Id, document.Digest); - var existing = await _collection - .Find(sessionHandle, filter) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - - var record = VexRawDocumentRecord.FromDomain(document, includeContent: useInline); - record.GridFsObjectId = useInline ? null : newGridId; - - await _collection - .ReplaceOneAsync( - sessionHandle, - filter, - record, - new ReplaceOptions { IsUpsert = true }, - cancellationToken) - .ConfigureAwait(false); - - if (existing?.GridFsObjectId is string oldGridId && !string.IsNullOrWhiteSpace(oldGridId)) - { - if (useInline || !string.Equals(newGridId, oldGridId, StringComparison.Ordinal)) - { - oldGridIdToDelete = oldGridId; - } - } - - if (startedTransaction) - { - await sessionHandle.CommitTransactionAsync(cancellationToken).ConfigureAwait(false); - } - } - catch - { - if (startedTransaction && sessionHandle.IsInTransaction) - { - await sessionHandle.AbortTransactionAsync(cancellationToken).ConfigureAwait(false); - } - - if (!useInline && !string.IsNullOrWhiteSpace(newGridId)) - { - await DeleteFromGridFsAsync(newGridId, sessionHandle, cancellationToken).ConfigureAwait(false); - } - - throw; - } - - if (!string.IsNullOrWhiteSpace(oldGridIdToDelete)) - { - await DeleteFromGridFsAsync(oldGridIdToDelete!, sessionHandle, cancellationToken).ConfigureAwait(false); - } - } - - public async ValueTask FindByDigestAsync(string digest, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - if (string.IsNullOrWhiteSpace(digest)) - { - throw new ArgumentException("Digest must be provided.", nameof(digest)); - } - - var trimmed = digest.Trim(); - var filter = Builders.Filter.Eq(x => x.Id, trimmed); - var record = session is null - ? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false) - : await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); - if (record is null) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(record.GridFsObjectId)) - { - var handle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false); - var bytes = await DownloadFromGridFsAsync(record.GridFsObjectId, handle, cancellationToken).ConfigureAwait(false); - return record.ToDomain(new ReadOnlyMemory(bytes)); - } - - return record.ToDomain(); - } - - private async Task UploadToGridFsAsync(VexRawDocument document, IClientSessionHandle? session, CancellationToken cancellationToken) - { - using var stream = new MemoryStream(document.Content.ToArray(), writable: false); - var metadata = new BsonDocument - { - { "providerId", document.ProviderId }, - { "format", document.Format.ToString().ToLowerInvariant() }, - { "sourceUri", document.SourceUri.ToString() }, - { "retrievedAt", document.RetrievedAt.UtcDateTime }, - }; - - var options = new GridFSUploadOptions { Metadata = metadata }; - var objectId = await _bucket - .UploadFromStreamAsync(document.Digest, stream, options, cancellationToken) - .ConfigureAwait(false); - - return objectId.ToString(); - } - - private async Task DeleteFromGridFsAsync(string gridFsObjectId, IClientSessionHandle? session, CancellationToken cancellationToken) - { - if (!ObjectId.TryParse(gridFsObjectId, out var objectId)) - { - return; - } - - try - { - await _bucket.DeleteAsync(objectId, cancellationToken).ConfigureAwait(false); - } - catch (GridFSFileNotFoundException) - { - // file already removed by TTL or manual cleanup - } - } - - private async Task DownloadFromGridFsAsync(string gridFsObjectId, IClientSessionHandle? session, CancellationToken cancellationToken) - { - if (!ObjectId.TryParse(gridFsObjectId, out var objectId)) - { - return Array.Empty(); - } - - return await _bucket.DownloadAsBytesAsync(objectId, null, cancellationToken).ConfigureAwait(false); - } - async ValueTask IVexRawDocumentSink.StoreAsync(VexRawDocument document, CancellationToken cancellationToken) - => await StoreAsync(document, cancellationToken, session: null).ConfigureAwait(false); + var sessionHandle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false); - private RawVexDocument CreateRawModel(VexRawDocument document) - { - var metadata = document.Metadata ?? ImmutableDictionary.Empty; - var tenant = _options.DefaultTenant; + if (!useInline) + { + newGridId = await UploadToGridFsAsync(document, sessionHandle, cancellationToken).ConfigureAwait(false); + } - var source = CreateSourceMetadata(document, metadata); - var content = CreateContent(document, metadata); - var upstream = CreateUpstreamMetadata(document, metadata); - var linkset = CreateLinkset(); - var statements = ImmutableArray.Empty; + var supportsTransactions = sessionHandle.Client.Cluster.Description.Type != ClusterType.Standalone + && !sessionHandle.IsInTransaction; - return new RawVexDocument( + var startedTransaction = false; + if (supportsTransactions) + { + try + { + sessionHandle.StartTransaction(); + startedTransaction = true; + } + catch (NotSupportedException) + { + supportsTransactions = false; + } + } + + var fetchWatch = Stopwatch.StartNew(); + using var fetchActivity = IngestionTelemetry.StartFetchActivity( tenant, - source, - upstream, - content, - linkset, - statements); - } - - private RawSourceMetadata CreateSourceMetadata(VexRawDocument document, ImmutableDictionary metadata) - { - var vendor = TryMetadata(metadata, "source.vendor", "connector.vendor") ?? ExtractVendor(document.ProviderId); - var connector = TryMetadata(metadata, "source.connector") ?? document.ProviderId; - var version = TryMetadata(metadata, "source.connector_version", "connector.version") ?? _connectorVersion; - var stream = TryMetadata(metadata, "source.stream", "connector.stream") ?? document.Format.ToString().ToLowerInvariant(); - - return new RawSourceMetadata( - vendor, - connector, - version, - stream); - } - - private RawUpstreamMetadata CreateUpstreamMetadata(VexRawDocument document, ImmutableDictionary metadata) - { - var upstreamId = TryMetadata( - metadata, - "upstream.id", - "aoc.upstream_id", - "vulnerability.id", - "advisory.id", - "msrc.vulnerabilityId", - "msrc.advisoryId", - "oracle.csaf.entryId", - "ubuntu.advisoryId", - "cisco.csaf.documentId", - "rancher.vex.id") ?? document.SourceUri.ToString(); - - var documentVersion = TryMetadata( - metadata, - "upstream.version", - "aoc.document_version", - "msrc.lastModified", - "msrc.releaseDate", - "oracle.csaf.revision", - "ubuntu.version", - "ubuntu.lastModified", - "cisco.csaf.revision") ?? document.RetrievedAt.ToString("O"); - - var signature = CreateSignatureMetadata(metadata); - var provenance = metadata; - - return new RawUpstreamMetadata( + sourceVendor, upstreamId, - documentVersion, - document.RetrievedAt, - document.Digest, - signature, - provenance); - } + contentHash, + document.SourceUri.ToString()); + fetchActivity?.SetTag("providerId", document.ProviderId); + fetchActivity?.SetTag("format", document.Format.ToString().ToLowerInvariant()); - private static RawSignatureMetadata CreateSignatureMetadata(ImmutableDictionary metadata) - { - if (!TryBool(metadata, out var present, "signature.present", "aoc.signature.present")) + VexRawDocumentRecord? existing; + try { - return new RawSignatureMetadata(false); + var filter = Builders.Filter.Eq(x => x.Id, document.Digest); + existing = await _collection + .Find(sessionHandle, filter) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + fetchActivity?.SetTag("result", existing is null ? "miss" : "hit"); + fetchActivity?.SetStatus(ActivityStatusCode.Ok); + } + catch + { + fetchActivity?.SetStatus(ActivityStatusCode.Error, "lookup-failed"); + throw; + } + finally + { + if (fetchWatch.IsRunning) + { + fetchWatch.Stop(); + } + + IngestionTelemetry.RecordLatency(tenant, sourceVendor, IngestionTelemetry.PhaseFetch, fetchWatch.Elapsed); } - if (!present) + var record = VexRawDocumentRecord.FromDomain(document, includeContent: useInline); + record.GridFsObjectId = useInline ? null : newGridId; + + var writeWatch = Stopwatch.StartNew(); + using var writeActivity = IngestionTelemetry.StartWriteActivity( + tenant, + sourceVendor, + upstreamId, + contentHash, + VexMongoCollectionNames.Raw); + string? writeResult = null; + + try { - return new RawSignatureMetadata(false); + await _collection + .ReplaceOneAsync( + sessionHandle, + Builders.Filter.Eq(x => x.Id, document.Digest), + record, + new ReplaceOptions { IsUpsert = true }, + cancellationToken) + .ConfigureAwait(false); + + writeResult = existing is null ? IngestionTelemetry.ResultOk : IngestionTelemetry.ResultNoop; + writeActivity?.SetTag("result", writeResult); + + if (existing?.GridFsObjectId is string oldGridId && !string.IsNullOrWhiteSpace(oldGridId)) + { + if (useInline || !string.Equals(newGridId, oldGridId, StringComparison.Ordinal)) + { + oldGridIdToDelete = oldGridId; + } + } + + if (startedTransaction) + { + await sessionHandle.CommitTransactionAsync(cancellationToken).ConfigureAwait(false); + } + + writeActivity?.SetStatus(ActivityStatusCode.Ok); + } + catch + { + if (startedTransaction && sessionHandle.IsInTransaction) + { + await sessionHandle.AbortTransactionAsync(cancellationToken).ConfigureAwait(false); + } + + if (!useInline && !string.IsNullOrWhiteSpace(newGridId)) + { + await DeleteFromGridFsAsync(newGridId, sessionHandle, cancellationToken).ConfigureAwait(false); + } + + writeActivity?.SetStatus(ActivityStatusCode.Error, "write-failed"); + throw; + } + finally + { + if (writeWatch.IsRunning) + { + writeWatch.Stop(); + } + + IngestionTelemetry.RecordLatency(tenant, sourceVendor, IngestionTelemetry.PhaseWrite, writeWatch.Elapsed); + + if (!string.IsNullOrEmpty(writeResult)) + { + IngestionTelemetry.RecordWriteAttempt(tenant, sourceVendor, writeResult); + } } - var format = TryMetadata(metadata, "signature.format", "aoc.signature.format"); - var keyId = TryMetadata(metadata, "signature.key_id", "signature.keyId", "aoc.signature.key_id"); - var signature = TryMetadata(metadata, "signature.sig", "signature.signature", "aoc.signature.sig"); - var digest = TryMetadata(metadata, "signature.digest", "aoc.signature.digest"); - var certificate = TryMetadata(metadata, "signature.certificate", "aoc.signature.certificate"); - - return new RawSignatureMetadata(true, format, keyId, signature, certificate, digest); + if (!string.IsNullOrWhiteSpace(oldGridIdToDelete)) + { + await DeleteFromGridFsAsync(oldGridIdToDelete!, sessionHandle, cancellationToken).ConfigureAwait(false); + } } - private RawContentMetadata CreateContent(VexRawDocument document, ImmutableDictionary metadata) + public async ValueTask FindByDigestAsync(string digest, CancellationToken cancellationToken, IClientSessionHandle? session = null) { - if (document.Content.IsEmpty) + if (string.IsNullOrWhiteSpace(digest)) { - throw new InvalidOperationException("Raw VEX document content cannot be empty when enforcing AOC guard."); + throw new ArgumentException("Digest must be provided.", nameof(digest)); + } + + var trimmed = digest.Trim(); + var filter = Builders.Filter.Eq(x => x.Id, trimmed); + var record = session is null + ? await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false) + : await _collection.Find(session, filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (record is null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(record.GridFsObjectId)) + { + var handle = session ?? await _sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false); + var bytes = await DownloadFromGridFsAsync(record.GridFsObjectId, handle, cancellationToken).ConfigureAwait(false); + return record.ToDomain(new ReadOnlyMemory(bytes)); + } + + return record.ToDomain(); + } + + private async Task UploadToGridFsAsync(VexRawDocument document, IClientSessionHandle? session, CancellationToken cancellationToken) + { + using var stream = new MemoryStream(document.Content.ToArray(), writable: false); + var metadata = new BsonDocument + { + { "providerId", document.ProviderId }, + { "format", document.Format.ToString().ToLowerInvariant() }, + { "sourceUri", document.SourceUri.ToString() }, + { "retrievedAt", document.RetrievedAt.UtcDateTime }, + }; + + var options = new GridFSUploadOptions { Metadata = metadata }; + var objectId = await _bucket + .UploadFromStreamAsync(document.Digest, stream, options, cancellationToken) + .ConfigureAwait(false); + + return objectId.ToString(); + } + + private async Task DeleteFromGridFsAsync(string gridFsObjectId, IClientSessionHandle? session, CancellationToken cancellationToken) + { + if (!ObjectId.TryParse(gridFsObjectId, out var objectId)) + { + return; } try { - using var payload = JsonDocument.Parse(document.Content.ToArray()); - var raw = payload.RootElement.Clone(); - var specVersion = TryMetadata(metadata, "content.spec_version", "csaf.version", "openvex.version"); - var encoding = TryMetadata(metadata, "content.encoding"); - - return new RawContentMetadata( - document.Format.ToString(), - specVersion, - raw, - encoding); + await _bucket.DeleteAsync(objectId, cancellationToken).ConfigureAwait(false); } - catch (JsonException ex) + catch (GridFSFileNotFoundException) { - throw new InvalidOperationException("Raw VEX document payload must be valid JSON for AOC guard enforcement.", ex); + // file already removed by TTL or manual cleanup } } - private static RawLinkset CreateLinkset() - => new() - { - Aliases = ImmutableArray.Empty, - PackageUrls = ImmutableArray.Empty, - Cpes = ImmutableArray.Empty, - References = ImmutableArray.Empty, - ReconciledFrom = ImmutableArray.Empty, - Notes = ImmutableDictionary.Empty, - }; - - private static string? TryMetadata(ImmutableDictionary metadata, params string[] keys) + private async Task DownloadFromGridFsAsync(string gridFsObjectId, IClientSessionHandle? session, CancellationToken cancellationToken) { - foreach (var key in keys) + if (!ObjectId.TryParse(gridFsObjectId, out var objectId)) { - if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) - { - return value; - } + return Array.Empty(); } - return null; + return await _bucket.DownloadAsBytesAsync(objectId, null, cancellationToken).ConfigureAwait(false); } - private static bool TryBool(ImmutableDictionary metadata, out bool value, params string[] keys) - { - foreach (var key in keys) - { - if (metadata.TryGetValue(key, out var text) && bool.TryParse(text, out value)) - { - return true; - } - } - - value = default; - return false; - } - - private static string ExtractVendor(string providerId) - { - if (string.IsNullOrWhiteSpace(providerId)) - { - return "unknown"; - } - - var trimmed = providerId.Trim(); - var separatorIndex = trimmed.LastIndexOfAny(new[] { ':', '.' }); - if (separatorIndex >= 0 && separatorIndex < trimmed.Length - 1) - { - return trimmed[(separatorIndex + 1)..]; - } - - return trimmed; - } + async ValueTask IVexRawDocumentSink.StoreAsync(VexRawDocument document, CancellationToken cancellationToken) + => await StoreAsync(document, cancellationToken, session: null).ConfigureAwait(false); } diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj index 28e43d617..c0f21e180 100644 --- a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj @@ -12,7 +12,8 @@ - - - - + + + + + diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexRawDocumentMapper.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexRawDocumentMapper.cs new file mode 100644 index 000000000..73581e7c8 --- /dev/null +++ b/src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo/VexRawDocumentMapper.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Immutable; +using System.Text.Json; +using StellaOps.Excititor.Core; +using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument; +using RawSourceMetadata = StellaOps.Concelier.RawModels.RawSourceMetadata; +using RawUpstreamMetadata = StellaOps.Concelier.RawModels.RawUpstreamMetadata; +using RawContentMetadata = StellaOps.Concelier.RawModels.RawContent; +using RawSignatureMetadata = StellaOps.Concelier.RawModels.RawSignatureMetadata; +using RawLinkset = StellaOps.Concelier.RawModels.RawLinkset; +using RawReference = StellaOps.Concelier.RawModels.RawReference; +using VexStatementSummaryModel = StellaOps.Concelier.RawModels.VexStatementSummary; + +namespace StellaOps.Excititor.Storage.Mongo; + +/// +/// Converts Excititor domain VEX documents into Aggregation-Only Contract raw payloads. +/// +public static class VexRawDocumentMapper +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static RawVexDocumentModel ToRawModel(VexRawDocument document, string defaultTenant) + { + ArgumentNullException.ThrowIfNull(document); + var metadata = document.Metadata ?? ImmutableDictionary.Empty; + var tenant = ResolveTenant(metadata, defaultTenant); + var source = CreateSourceMetadata(document, metadata); + var upstream = CreateUpstreamMetadata(document, metadata); + var content = CreateContent(document, metadata); + var linkset = CreateLinkset(); + ImmutableArray? statements = null; + return new RawVexDocumentModel(tenant, source, upstream, content, linkset, statements); + } + + private static string ResolveTenant(ImmutableDictionary metadata, string fallback) + { + var tenant = TryMetadata(metadata, "tenant", "aoc.tenant"); + if (string.IsNullOrWhiteSpace(tenant)) + { + return (fallback ?? "tenant-default").Trim().ToLowerInvariant(); + } + + return tenant.Trim().ToLowerInvariant(); + } + + private static RawSourceMetadata CreateSourceMetadata(VexRawDocument document, ImmutableDictionary metadata) + { + var vendor = TryMetadata(metadata, "source.vendor", "connector.vendor") ?? ExtractVendor(document.ProviderId); + var connector = TryMetadata(metadata, "source.connector") ?? document.ProviderId; + var version = TryMetadata(metadata, "source.connector_version", "connector.version") ?? GetAssemblyVersion(); + var stream = TryMetadata(metadata, "source.stream", "connector.stream") ?? document.Format.ToString().ToLowerInvariant(); + return new RawSourceMetadata(vendor, connector, version, stream); + } + + private static string GetAssemblyVersion() + => typeof(VexRawDocumentMapper).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + private static RawUpstreamMetadata CreateUpstreamMetadata(VexRawDocument document, ImmutableDictionary metadata) + { + var upstreamId = TryMetadata( + metadata, + "upstream.id", + "aoc.upstream_id", + "vulnerability.id", + "advisory.id", + "msrc.vulnerabilityId", + "msrc.advisoryId", + "oracle.csaf.entryId", + "ubuntu.advisoryId", + "cisco.csaf.documentId", + "rancher.vex.id") ?? document.SourceUri.ToString(); + + var documentVersion = TryMetadata( + metadata, + "upstream.version", + "aoc.document_version", + "msrc.lastModified", + "msrc.releaseDate", + "oracle.csaf.revision", + "ubuntu.version", + "ubuntu.lastModified", + "cisco.csaf.revision") ?? document.RetrievedAt.ToString("O"); + + var signature = CreateSignatureMetadata(metadata); + return new RawUpstreamMetadata( + upstreamId, + documentVersion, + document.RetrievedAt, + document.Digest, + signature, + metadata); + } + + private static RawSignatureMetadata CreateSignatureMetadata(ImmutableDictionary metadata) + { + if (!TryBool(metadata, out var present, "signature.present", "aoc.signature.present")) + { + return new RawSignatureMetadata(false); + } + + if (!present) + { + return new RawSignatureMetadata(false); + } + + var format = TryMetadata(metadata, "signature.format", "aoc.signature.format"); + var keyId = TryMetadata(metadata, "signature.key_id", "signature.keyId", "aoc.signature.key_id"); + var signature = TryMetadata(metadata, "signature.sig", "signature.signature", "aoc.signature.sig"); + var digest = TryMetadata(metadata, "signature.digest", "aoc.signature.digest"); + var certificate = TryMetadata(metadata, "signature.certificate", "aoc.signature.certificate"); + return new RawSignatureMetadata(true, format, keyId, signature, certificate, digest); + } + + private static RawContentMetadata CreateContent(VexRawDocument document, ImmutableDictionary metadata) + { + if (document.Content.IsEmpty) + { + throw new InvalidOperationException("Raw VEX document content cannot be empty when enforcing AOC guard."); + } + + try + { + using var payload = JsonDocument.Parse(document.Content.ToArray()); + var raw = payload.RootElement.Clone(); + var specVersion = TryMetadata(metadata, "content.spec_version", "csaf.version", "openvex.version"); + var encoding = TryMetadata(metadata, "content.encoding"); + return new RawContentMetadata( + document.Format.ToString(), + specVersion, + raw, + encoding); + } + catch (JsonException ex) + { + throw new InvalidOperationException("Raw VEX document payload must be valid JSON for AOC guard enforcement.", ex); + } + } + + private static RawLinkset CreateLinkset() + => new() + { + Aliases = ImmutableArray.Empty, + PackageUrls = ImmutableArray.Empty, + Cpes = ImmutableArray.Empty, + References = ImmutableArray.Empty, + ReconciledFrom = ImmutableArray.Empty, + Notes = ImmutableDictionary.Empty, + }; + + private static string? TryMetadata(ImmutableDictionary metadata, params string[] keys) + { + foreach (var key in keys) + { + if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private static bool TryBool(ImmutableDictionary metadata, out bool value, params string[] keys) + { + foreach (var key in keys) + { + if (metadata.TryGetValue(key, out var text) && bool.TryParse(text, out value)) + { + return true; + } + } + + value = default; + return false; + } + + private static string ExtractVendor(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "unknown"; + } + + var trimmed = providerId.Trim(); + var separatorIndex = trimmed.LastIndexOfAny(new[] { ':', '.' }); + if (separatorIndex >= 0 && separatorIndex < trimmed.Length - 1) + { + return trimmed[(separatorIndex + 1)..]; + } + + return trimmed; + } +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexCacheMaintenanceTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexCacheMaintenanceTests.cs index 9da04a4da..7a26e091a 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexCacheMaintenanceTests.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexCacheMaintenanceTests.cs @@ -1,23 +1,20 @@ using System.Collections.Generic; -using Microsoft.Extensions.Logging.Abstractions; -using Mongo2Go; -using MongoDB.Bson; -using MongoDB.Driver; - -namespace StellaOps.Excititor.Storage.Mongo.Tests; - -public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime -{ - private readonly MongoDbRunner _runner; - private readonly IMongoDatabase _database; - - public MongoVexCacheMaintenanceTests() - { - _runner = MongoDbRunner.Start(); - var client = new MongoClient(_runner.ConnectionString); - _database = client.GetDatabase("vex-cache-maintenance-tests"); - VexMongoMappingRegistry.Register(); - } +using Microsoft.Extensions.Logging.Abstractions; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace StellaOps.Excititor.Storage.Mongo.Tests; + +public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime +{ + private readonly TestMongoEnvironment _mongo = new(); + private readonly IMongoDatabase _database; + + public MongoVexCacheMaintenanceTests() + { + _database = _mongo.CreateDatabase("cache-maintenance"); + VexMongoMappingRegistry.Register(); + } [Fact] public async Task RemoveExpiredAsync_DeletesEntriesBeforeCutoff() @@ -114,9 +111,5 @@ public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime public Task InitializeAsync() => Task.CompletedTask; - public Task DisposeAsync() - { - _runner.Dispose(); - return Task.CompletedTask; - } -} + public Task DisposeAsync() => _mongo.DisposeAsync(); +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexRepositoryTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexRepositoryTests.cs index 9d74f5d98..9f4b1c422 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexRepositoryTests.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexRepositoryTests.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Linq; using System.Text; using Microsoft.Extensions.Options; -using Mongo2Go; using MongoDB.Bson; using MongoDB.Driver; using StellaOps.Aoc; @@ -13,21 +12,20 @@ using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument; namespace StellaOps.Excititor.Storage.Mongo.Tests; -public sealed class MongoVexRepositoryTests : IAsyncLifetime -{ - private readonly MongoDbRunner _runner; - private readonly MongoClient _client; - - public MongoVexRepositoryTests() - { - _runner = MongoDbRunner.Start(); - _client = new MongoClient(_runner.ConnectionString); - } +public sealed class MongoVexRepositoryTests : IAsyncLifetime +{ + private readonly TestMongoEnvironment _mongo = new(); + private readonly MongoClient _client; + + public MongoVexRepositoryTests() + { + _client = _mongo.Client; + } [Fact] public async Task RawStore_UsesGridFsForLargePayloads() { - var database = _client.GetDatabase($"vex-raw-gridfs-{Guid.NewGuid():N}"); + var database = _mongo.CreateDatabase("vex-raw-gridfs"); var store = CreateRawStore(database, thresholdBytes: 32); var payload = CreateJsonPayload(new string('A', 256)); @@ -63,7 +61,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime [Fact] public async Task RawStore_ReplacesGridFsWithInlinePayload() { - var database = _client.GetDatabase($"vex-raw-inline-{Guid.NewGuid():N}"); + var database = _mongo.CreateDatabase("vex-raw-inline"); var store = CreateRawStore(database, thresholdBytes: 16); var largePayload = CreateJsonPayload(new string('B', 128)); @@ -176,7 +174,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime [Fact] public async Task ExportStore_FindAsync_ExpiresCacheEntries() { - var database = _client.GetDatabase($"vex-export-expire-{Guid.NewGuid():N}"); + var database = _mongo.CreateDatabase("vex-export-expire"); var options = Options.Create(new VexMongoStorageOptions { ExportCacheTtl = TimeSpan.FromMinutes(5), @@ -217,7 +215,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime [Fact] public async Task ClaimStore_AppendsAndQueriesStatements() { - var database = _client.GetDatabase($"vex-claims-{Guid.NewGuid():N}"); + var database = _mongo.CreateDatabase("vex-claims"); var store = new MongoVexClaimStore(database); var product = new VexProduct("pkg:demo/app", "Demo App", version: "1.0.0", purl: "pkg:demo/app@1.0.0"); @@ -305,11 +303,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime public Task InitializeAsync() => Task.CompletedTask; - public Task DisposeAsync() - { - _runner.Dispose(); - return Task.CompletedTask; - } + public Task DisposeAsync() => _mongo.DisposeAsync(); private static byte[] CreateJsonPayload(string value) => Encoding.UTF8.GetBytes(CreateJsonPayloadString(value)); diff --git a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexSessionConsistencyTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexSessionConsistencyTests.cs index 019f9e5f8..2f6d9d5bd 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexSessionConsistencyTests.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexSessionConsistencyTests.cs @@ -2,23 +2,23 @@ using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Mongo2Go; -using MongoDB.Bson; -using MongoDB.Driver; -using StellaOps.Excititor.Core; - -namespace StellaOps.Excititor.Storage.Mongo.Tests; - -public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime -{ - private readonly MongoDbRunner _runner; - - public MongoVexSessionConsistencyTests() - { - _runner = MongoDbRunner.Start(singleNodeReplSet: true); - } +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.Excititor.Core; + +namespace StellaOps.Excititor.Storage.Mongo.Tests; + +public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime +{ + private readonly TestMongoEnvironment _mongo = new(); + private readonly MongoClient _client; + + public MongoVexSessionConsistencyTests() + { + _client = _mongo.Client; + } [Fact] public async Task SessionProvidesReadYourWrites() @@ -45,7 +45,7 @@ public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime await using var provider = BuildServiceProvider(); await using var scope = provider.CreateAsyncScope(); - var client = scope.ServiceProvider.GetRequiredService(); + var client = scope.ServiceProvider.GetRequiredService(); var sessionProvider = scope.ServiceProvider.GetRequiredService(); var providerStore = scope.ServiceProvider.GetRequiredService(); @@ -74,18 +74,18 @@ public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime private ServiceProvider BuildServiceProvider() { - var services = new ServiceCollection(); - services.AddLogging(builder => builder.AddDebug()); - services.Configure(options => - { - options.ConnectionString = _runner.ConnectionString; - options.DatabaseName = $"excititor-session-tests-{Guid.NewGuid():N}"; - options.CommandTimeout = TimeSpan.FromSeconds(5); - options.RawBucketName = "vex.raw"; - }); - services.AddExcititorMongoStorage(); - return services.BuildServiceProvider(); - } + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddDebug()); + services.Configure(options => + { + options.ConnectionString = _mongo.ConnectionString; + options.DatabaseName = _mongo.ReserveDatabase("session"); + options.CommandTimeout = TimeSpan.FromSeconds(5); + options.RawBucketName = "vex.raw"; + }); + services.AddExcititorMongoStorage(); + return services.BuildServiceProvider(); + } private static async Task ExecuteWithRetryAsync(Func action, CancellationToken cancellationToken) { @@ -176,9 +176,5 @@ public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime public Task InitializeAsync() => Task.CompletedTask; - public Task DisposeAsync() - { - _runner.Dispose(); - return Task.CompletedTask; - } -} + public Task DisposeAsync() => _mongo.DisposeAsync(); +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStatementBackfillServiceTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStatementBackfillServiceTests.cs index 78d87410b..94f149c85 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStatementBackfillServiceTests.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStatementBackfillServiceTests.cs @@ -3,22 +3,22 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Mongo2Go; +using Microsoft.Extensions.Logging; using System.Text; using StellaOps.Excititor.Core; using StellaOps.Excititor.Core.Aoc; - -namespace StellaOps.Excititor.Storage.Mongo.Tests; - -public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime -{ - private readonly MongoDbRunner _runner; - - public MongoVexStatementBackfillServiceTests() - { - _runner = MongoDbRunner.Start(singleNodeReplSet: true); - } +using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument; + +namespace StellaOps.Excititor.Storage.Mongo.Tests; + +public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime +{ + private readonly TestMongoEnvironment _mongo = new(); + + public MongoVexStatementBackfillServiceTests() + { + // Intentionally left blank; Mongo environment is initialized on demand. + } [Fact] public async Task RunAsync_BackfillsStatementsFromRawDocuments() @@ -108,34 +108,32 @@ public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime var services = new ServiceCollection(); services.AddLogging(builder => builder.AddDebug()); services.AddSingleton(TimeProvider.System); - services.Configure(options => - { - options.ConnectionString = _runner.ConnectionString; - options.DatabaseName = $"excititor-backfill-tests-{Guid.NewGuid():N}"; - options.CommandTimeout = TimeSpan.FromSeconds(5); - options.RawBucketName = "vex.raw"; - options.GridFsInlineThresholdBytes = 1024; - options.ExportCacheTtl = TimeSpan.FromHours(1); - }); + services.Configure(options => + { + options.ConnectionString = _mongo.ConnectionString; + options.DatabaseName = _mongo.ReserveDatabase("backfill"); + options.CommandTimeout = TimeSpan.FromSeconds(5); + options.RawBucketName = "vex.raw"; + options.GridFsInlineThresholdBytes = 1024; + options.ExportCacheTtl = TimeSpan.FromHours(1); + options.DefaultTenant = "tests"; + }); services.AddExcititorMongoStorage(); services.AddExcititorAocGuards(); + services.AddSingleton(); services.AddSingleton(); return services.BuildServiceProvider(); } public Task InitializeAsync() => Task.CompletedTask; - public Task DisposeAsync() - { - _runner.Dispose(); - return Task.CompletedTask; - } + public Task DisposeAsync() => _mongo.DisposeAsync(); private static ReadOnlyMemory CreateJsonPayload(string value) => Encoding.UTF8.GetBytes($"{{\"data\":\"{value}\"}}"); - private sealed class TestNormalizer : IVexNormalizer - { + private sealed class TestNormalizer : IVexNormalizer + { public string Format => "csaf"; public bool CanHandle(VexRawDocument document) => true; @@ -171,6 +169,14 @@ public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime var claims = ImmutableArray.Create(claim); return ValueTask.FromResult(new VexClaimBatch(document, claims, ImmutableDictionary.Empty)); - } - } -} + } + } + + private sealed class PermissiveVexRawWriteGuard : IVexRawWriteGuard + { + public void EnsureValid(RawVexDocumentModel document) + { + // Tests control the payloads; guard bypass keeps focus on backfill logic. + } + } +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStoreMappingTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStoreMappingTests.cs index 9c46bd5e9..e8d73e23e 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStoreMappingTests.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/MongoVexStoreMappingTests.cs @@ -1,23 +1,20 @@ using System.Globalization; -using Mongo2Go; -using MongoDB.Bson; -using MongoDB.Driver; -using StellaOps.Excititor.Core; - -namespace StellaOps.Excititor.Storage.Mongo.Tests; - -public sealed class MongoVexStoreMappingTests : IAsyncLifetime -{ - private readonly MongoDbRunner _runner; - private readonly IMongoDatabase _database; - - public MongoVexStoreMappingTests() - { - _runner = MongoDbRunner.Start(); - var client = new MongoClient(_runner.ConnectionString); - _database = client.GetDatabase("excititor-storage-mapping-tests"); - VexMongoMappingRegistry.Register(); - } +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.Excititor.Core; + +namespace StellaOps.Excititor.Storage.Mongo.Tests; + +public sealed class MongoVexStoreMappingTests : IAsyncLifetime +{ + private readonly TestMongoEnvironment _mongo = new(); + private readonly IMongoDatabase _database; + + public MongoVexStoreMappingTests() + { + _database = _mongo.CreateDatabase("storage-mapping"); + VexMongoMappingRegistry.Register(); + } [Fact] public async Task ProviderStore_RoundTrips_WithExtraFields() @@ -259,9 +256,5 @@ public sealed class MongoVexStoreMappingTests : IAsyncLifetime public Task InitializeAsync() => Task.CompletedTask; - public Task DisposeAsync() - { - _runner.Dispose(); - return Task.CompletedTask; - } -} + public Task DisposeAsync() => _mongo.DisposeAsync(); +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/TestMongoEnvironment.cs b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/TestMongoEnvironment.cs new file mode 100644 index 000000000..4508c4af4 --- /dev/null +++ b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/TestMongoEnvironment.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Mongo2Go; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace StellaOps.Excititor.Storage.Mongo.Tests; + +internal sealed class TestMongoEnvironment : IAsyncLifetime +{ + private const string Prefix = "exstor"; + private readonly MongoDbRunner? _runner; + private readonly HashSet _reservedDatabases = new(StringComparer.Ordinal); + + public TestMongoEnvironment() + { + var overrideConnection = Environment.GetEnvironmentVariable("EXCITITOR_TEST_MONGO_URI"); + if (!string.IsNullOrWhiteSpace(overrideConnection)) + { + ConnectionString = overrideConnection.Trim(); + Client = new MongoClient(ConnectionString); + return; + } + + _runner = MongoDbRunner.Start(singleNodeReplSet: true); + ConnectionString = _runner.ConnectionString; + Client = new MongoClient(ConnectionString); + } + + public MongoClient Client { get; } + + public string ConnectionString { get; } + + public string ReserveDatabase(string hint) + { + var baseName = string.IsNullOrWhiteSpace(hint) ? "db" : hint.ToLowerInvariant(); + var builder = new StringBuilder(baseName.Length); + foreach (var ch in baseName) + { + builder.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + } + + var slug = builder.Length == 0 ? "db" : builder.ToString(); + var suffix = ObjectId.GenerateNewId().ToString(); + var maxSlugLength = Math.Max(1, 60 - Prefix.Length - suffix.Length - 2); + if (slug.Length > maxSlugLength) + { + slug = slug[..maxSlugLength]; + } + + var name = $"{Prefix}_{slug}_{suffix}"; + _reservedDatabases.Add(name); + return name; + } + + public IMongoDatabase CreateDatabase(string hint) + { + var name = ReserveDatabase(hint); + return Client.GetDatabase(name); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_runner is not null) + { + _runner.Dispose(); + return; + } + + foreach (var db in _reservedDatabases) + { + try + { + await Client.DropDatabaseAsync(db); + } + catch (MongoException) + { + // best-effort cleanup when sharing a developer-managed instance. + } + } + + _reservedDatabases.Clear(); + } +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/VexMongoMigrationRunnerTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/VexMongoMigrationRunnerTests.cs index 495633ada..2cbc82fd5 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/VexMongoMigrationRunnerTests.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.Storage.Mongo.Tests/VexMongoMigrationRunnerTests.cs @@ -1,25 +1,22 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; -using Mongo2Go; -using MongoDB.Driver; -using StellaOps.Excititor.Storage.Mongo.Migrations; -using StellaOps.Excititor.Storage.Mongo; +using Microsoft.Extensions.Logging.Abstractions; +using MongoDB.Driver; +using StellaOps.Excititor.Storage.Mongo.Migrations; +using StellaOps.Excititor.Storage.Mongo; namespace StellaOps.Excititor.Storage.Mongo.Tests; -public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime -{ - private readonly MongoDbRunner _runner; - private readonly IMongoDatabase _database; - - public VexMongoMigrationRunnerTests() - { - _runner = MongoDbRunner.Start(); - var client = new MongoClient(_runner.ConnectionString); - _database = client.GetDatabase("excititor-migrations-tests"); - } +public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime +{ + private readonly TestMongoEnvironment _mongo = new(); + private readonly IMongoDatabase _database; + + public VexMongoMigrationRunnerTests() + { + _database = _mongo.CreateDatabase("migrations"); + } [Fact] public async Task RunAsync_AppliesInitialIndexesOnce() @@ -60,9 +57,5 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime public Task InitializeAsync() => Task.CompletedTask; - public Task DisposeAsync() - { - _runner.Dispose(); - return Task.CompletedTask; - } -} + public Task DisposeAsync() => _mongo.DisposeAsync(); +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/BatchIngestValidationTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/BatchIngestValidationTests.cs new file mode 100644 index 000000000..1197bc04f --- /dev/null +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/BatchIngestValidationTests.cs @@ -0,0 +1,451 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using EphemeralMongo; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Concelier.RawModels; +using StellaOps.Excititor.WebService.Contracts; +using StellaOps.Ingestion.Telemetry; +using Xunit; + +namespace StellaOps.Excititor.WebService.Tests; + +public sealed class BatchIngestValidationTests : IDisposable +{ + private const string Tenant = "tests"; + + private readonly IMongoRunner _runner; + private readonly TestWebApplicationFactory _factory; + + public BatchIngestValidationTests() + { + _runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true }); + + _factory = new TestWebApplicationFactory( + configureConfiguration: configuration => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString, + ["Excititor:Storage:Mongo:DatabaseName"] = "vex_batch_tests", + ["Excititor:Storage:Mongo:DefaultTenant"] = Tenant, + }); + }, + configureServices: services => + { + TestServiceOverrides.Apply(services); + services.AddTestAuthentication(); + }); + } + + [Fact] + [Trait("Category", "BatchIngestValidation")] + public async Task BatchFixturesMaintainParityMetricsAndVerify() + { + using var metrics = new IngestionMetricListener(); + using var client = CreateClient(); + + var fixtures = VexFixtureLibrary.CreateBatch(); + foreach (var fixture in fixtures) + { + var ingestResponse = await client.PostAsJsonAsync("/ingest/vex", fixture.Request); + ingestResponse.EnsureSuccessStatusCode(); + var payload = await ingestResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + fixture.RecordDigest(payload!.Digest); + } + + var listResponse = await client.GetAsync($"/vex/raw?limit={fixtures.Count * 2}"); + listResponse.EnsureSuccessStatusCode(); + var listPayload = await listResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(listPayload); + foreach (var fixture in fixtures) + { + Assert.Contains(listPayload!.Records, record => record.Digest == fixture.Digest); + } + + foreach (var fixture in fixtures) + { + var recordResponse = await client.GetAsync($"/vex/raw/{Uri.EscapeDataString(fixture.Digest)}"); + recordResponse.EnsureSuccessStatusCode(); + var record = await recordResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(record); + fixture.AssertRecord(record!); + } + + var verifyRequest = new VexAocVerifyRequest( + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddMinutes(5), + fixtures.Count + 5, + null, + null); + var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", verifyRequest); + verifyResponse.EnsureSuccessStatusCode(); + var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(verifyPayload); + Assert.Equal(Tenant, verifyPayload!.Tenant); + Assert.Equal(fixtures.Count, verifyPayload.Checked.Vex); + Assert.Empty(verifyPayload.Violations); + Assert.Equal(fixtures.Count, verifyPayload.Metrics.IngestionWriteTotal); + Assert.Equal(0, verifyPayload.Metrics.AocViolationTotal); + Assert.False(verifyPayload.Truncated); + + Assert.True(metrics.WaitForMeasurements(fixtures.Count, TimeSpan.FromSeconds(2))); + foreach (var measurement in metrics.GetMeasurements()) + { + Assert.Equal(Tenant, measurement.Tenant); + Assert.Equal(IngestionTelemetry.ResultOk, measurement.Result); + Assert.Equal(1, measurement.Value); + } + } + + private HttpClient CreateClient() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin vex.read"); + client.DefaultRequestHeaders.Add("X-Stella-Tenant", Tenant); + return client; + } + + public void Dispose() + { + _factory.Dispose(); + _runner.Dispose(); + } + + private sealed class IngestionMetricListener : IDisposable + { + private readonly List _measurements = new(); + private readonly MeterListener _listener; + + public IngestionMetricListener() + { + _listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == IngestionTelemetry.MeterName && + instrument.Name == "ingestion_write_total") + { + listener.EnableMeasurementEvents(instrument); + } + } + }; + + _listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + { + if (instrument.Meter.Name != IngestionTelemetry.MeterName || + instrument.Name != "ingestion_write_total") + { + return; + } + + string tenant = string.Empty; + string source = string.Empty; + string result = string.Empty; + + foreach (var tag in tags) + { + switch (tag.Key) + { + case "tenant": + tenant = tag.Value?.ToString() ?? string.Empty; + break; + case "source": + source = tag.Value?.ToString() ?? string.Empty; + break; + case "result": + result = tag.Value?.ToString() ?? string.Empty; + break; + } + } + + lock (_measurements) + { + _measurements.Add(new Measurement(tenant, source, result, measurement)); + } + }); + + _listener.Start(); + } + + public bool WaitForMeasurements(int expected, TimeSpan timeout) + { + var sw = Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + lock (_measurements) + { + if (_measurements.Count >= expected) + { + return true; + } + } + + Thread.Sleep(25); + } + + return false; + } + + public IReadOnlyList GetMeasurements() + { + lock (_measurements) + { + return _measurements.ToList(); + } + } + + public void Dispose() => _listener.Dispose(); + + internal sealed record Measurement(string Tenant, string Source, string Result, long Value); + } + + private sealed record VexFixture( + string Name, + VexIngestRequest Request, + string ExpectedFormat, + Action ContentAssertion) + { + private string? _digest; + + public string Digest => _digest ?? throw new InvalidOperationException("Digest not recorded yet."); + + public void RecordDigest(string digest) + { + _digest = digest ?? throw new ArgumentNullException(nameof(digest)); + } + + public void AssertRecord(VexRawRecordResponse record) + { + Assert.Equal(ExpectedFormat, record.Document.Content.Format, StringComparer.OrdinalIgnoreCase); + ContentAssertion(record.Document.Content.Raw); + } + } + + private static class VexFixtureLibrary + { + public static IReadOnlyList CreateBatch() + => new[] + { + CreateCycloneDxFixture("001", "sha256:batch-cdx-001", "CDX-BATCH-001", "not_affected"), + CreateCycloneDxFixture("002", "sha256:batch-cdx-002", "CDX-BATCH-002", "affected"), + CreateCycloneDxFixture("003", "sha256:batch-cdx-003", "CDX-BATCH-003", "fixed"), + CreateCsafFixture("010", "sha256:batch-csaf-001", "CSAF-BATCH-001", "fixed"), + CreateCsafFixture("011", "sha256:batch-csaf-002", "CSAF-BATCH-002", "known_affected"), + CreateCsafFixture("012", "sha256:batch-csaf-003", "CSAF-BATCH-003", "known_not_affected"), + CreateOpenVexFixture("020", "sha256:batch-openvex-001", "OVX-BATCH-001", "affected"), + CreateOpenVexFixture("021", "sha256:batch-openvex-002", "OVX-BATCH-002", "not_affected"), + CreateOpenVexFixture("022", "sha256:batch-openvex-003", "OVX-BATCH-003", "fixed") + }; + + private static VexFixture CreateCycloneDxFixture(string suffix, string digest, string upstreamId, string state) + { + var vulnerabilityId = $"CVE-2025-{suffix}"; + var raw = $$""" + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2025-11-08T00:00:00Z", + "tools": [ + { "vendor": "stellaops", "name": "batch-cdx" } + ] + }, + "vulnerabilities": [ + { + "id": "{{vulnerabilityId}}", + "analysis": { "state": "{{state}}" }, + "ratings": [ + { "score": 0.0, "method": "cvssv3" } + ] + } + ] + } + """; + + return new VexFixture( + $"cyclonedx-{suffix}", + BuildRequest( + providerId: $"cyclonedx:batch:{suffix}", + vendor: "vendor:cyclonedx", + connector: "cdx-batch", + stream: "cyclonedx-vex", + format: "cyclonedx", + specVersion: "1.6", + rawJson: raw, + digest: digest, + upstreamId: upstreamId, + sourceUri: $"https://example.test/vex/cyclonedx/{suffix}"), + "cyclonedx", + element => + { + var actual = element + .GetProperty("vulnerabilities")[0] + .GetProperty("analysis") + .GetProperty("state") + .GetString(); + Assert.Equal(state, actual); + }); + } + + private static VexFixture CreateCsafFixture(string suffix, string digest, string upstreamId, string statusKey) + { + var cve = $"CVE-2025-{suffix}"; + var productId = $"csaf-prod-{suffix}"; + var raw = $$""" + { + "document": { + "category": "csaf_vex", + "title": "Sample CSAF VEX", + "tracking": { + "id": "CSAF-2025-{{suffix}}", + "version": "1", + "current_release_date": "2025-11-07T00:00:00Z", + "initial_release_date": "2025-11-07T00:00:00Z", + "status": "final" + } + }, + "product_tree": { + "branches": [ + { + "name": "products", + "product": { + "name": "sample-product-{{suffix}}", + "product_id": "{{productId}}" + } + } + ] + }, + "vulnerabilities": [ + { + "cve": "{{cve}}", + "product_status": { + "{{statusKey}}": [ "{{productId}}" ] + }, + "threats": [ + { "category": "impact", "details": "none" } + ] + } + ] + } + """; + + return new VexFixture( + $"csaf-{suffix}", + BuildRequest( + providerId: $"csaf:batch:{suffix}", + vendor: "vendor:csaf", + connector: "csaf-batch", + stream: "csaf-vex", + format: "csaf", + specVersion: "2.0", + rawJson: raw, + digest: digest, + upstreamId: upstreamId, + sourceUri: $"https://example.test/vex/csaf/{suffix}"), + "csaf", + element => + { + var productStatus = element + .GetProperty("vulnerabilities")[0] + .GetProperty("product_status") + .GetProperty(statusKey) + .EnumerateArray() + .First() + .GetString(); + Assert.Equal(productId, productStatus); + }); + } + + private static VexFixture CreateOpenVexFixture(string suffix, string digest, string upstreamId, string status) + { + var raw = $$""" + { + "context": "https://openvex.dev/ns/v0.2.0", + "statements": [ + { + "vulnerability": "CVE-2025-{{suffix}}", + "products": [ + "pkg:docker/demo@sha256:{{digest}}" + ], + "status": "{{status}}", + "statusNotes": "waiting on vendor patch" + } + ] + } + """; + + return new VexFixture( + $"openvex-{suffix}", + BuildRequest( + providerId: $"openvex:batch:{suffix}", + vendor: "vendor:openvex", + connector: "openvex-batch", + stream: "openvex", + format: "openvex", + specVersion: "0.2.0", + rawJson: raw, + digest: digest, + upstreamId: upstreamId, + sourceUri: $"https://example.test/vex/openvex/{suffix}"), + "openvex", + element => + { + var actual = element + .GetProperty("statements")[0] + .GetProperty("status") + .GetString(); + Assert.Equal(status, actual); + }); + } + + private static VexIngestRequest BuildRequest( + string providerId, + string vendor, + string connector, + string stream, + string format, + string specVersion, + string rawJson, + string digest, + string upstreamId, + string sourceUri) + { + using var rawDocument = JsonDocument.Parse(rawJson); + var metadata = new Dictionary + { + ["source.vendor"] = vendor, + ["source.connector"] = connector, + ["source.stream"] = stream, + ["source.connector_version"] = "1.0.0" + }; + + return new VexIngestRequest( + providerId, + new VexIngestSourceRequest(vendor, connector, "1.0.0", stream), + new VexIngestUpstreamRequest( + sourceUri, + upstreamId, + "1", + DateTimeOffset.UtcNow, + digest, + new VexIngestSignatureRequest(false, null, null, null, null, null), + new Dictionary()), + new VexIngestContentRequest(format, specVersion, rawDocument.RootElement.Clone(), null), + metadata); + } + } +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/ObservabilityEndpointTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/ObservabilityEndpointTests.cs new file mode 100644 index 000000000..b3455bfee --- /dev/null +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/ObservabilityEndpointTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using EphemeralMongo; +using MongoDB.Bson; +using MongoDB.Driver; +using Xunit; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.Storage.Mongo; +using StellaOps.Excititor.WebService.Services; + +namespace StellaOps.Excititor.WebService.Tests; + +public sealed class ObservabilityEndpointTests : IDisposable +{ + private readonly TestWebApplicationFactory _factory; + private readonly IMongoRunner _runner; + + public ObservabilityEndpointTests() + { + _runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true }); + + _factory = new TestWebApplicationFactory( + configureConfiguration: configuration => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString, + ["Excititor:Storage:Mongo:DatabaseName"] = "excititor_obs_tests", + ["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw", + ["Excititor:Observability:IngestWarningThreshold"] = "00:10:00", + ["Excititor:Observability:IngestCriticalThreshold"] = "00:30:00", + ["Excititor:Observability:SignatureWindow"] = "00:30:00", + ["Excititor:Observability:ConflictTrendWindow"] = "01:00:00", + ["Excititor:Observability:ConflictTrendBucketMinutes"] = "5" + }); + }, + configureServices: services => + { + TestServiceOverrides.Apply(services); + services.AddTestAuthentication(); + + services.RemoveAll(); + services.AddScoped(); + services.AddSingleton(_ => new StubConnector("excititor:redhat", VexProviderKind.Distro)); + }); + + SeedDatabase(); + } + + [Fact] + public async Task HealthEndpoint_ReturnsAggregatedMetrics() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin"); + + using var response = await client.GetAsync("/obs/excititor/health"); + var payload = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, payload); + + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + + var ingest = root.GetProperty("ingest"); + Assert.Equal("healthy", ingest.GetProperty("status").GetString()); + + var connectors = ingest.GetProperty("connectors"); + Assert.Equal(1, connectors.GetArrayLength()); + Assert.Equal("excititor:redhat", connectors[0].GetProperty("connectorId").GetString()); + + var signature = root.GetProperty("signature"); + Assert.Equal(3, signature.GetProperty("documentsEvaluated").GetInt32()); + Assert.Equal(1, signature.GetProperty("failures").GetInt32()); + Assert.Equal(1, signature.GetProperty("verified").GetInt32()); + + var conflicts = root.GetProperty("conflicts"); + Assert.True(conflicts.GetProperty("conflictStatements").GetInt64() >= 2); + Assert.True(conflicts.GetProperty("trend").GetArrayLength() >= 1); + } + + private void SeedDatabase() + { + using var scope = _factory.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + database.DropCollection(VexMongoCollectionNames.Raw); + database.DropCollection(VexMongoCollectionNames.Consensus); + database.DropCollection(VexMongoCollectionNames.ConnectorState); + + var now = DateTime.UtcNow; + var rawCollection = database.GetCollection(VexMongoCollectionNames.Raw); + rawCollection.InsertMany(new[] + { + new BsonDocument + { + { "Id", "raw-1" }, + { "ProviderId", "excititor:redhat" }, + { ObservabilityEndpointTestsHelper.RetrievedAtField, now }, + { ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument { { "signature.present", "true" }, { "signature.verified", "true" } } } + }, + new BsonDocument + { + { "Id", "raw-2" }, + { "ProviderId", "excititor:redhat" }, + { ObservabilityEndpointTestsHelper.RetrievedAtField, now }, + { ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument { { "signature.present", "true" } } } + }, + new BsonDocument + { + { "Id", "raw-3" }, + { "ProviderId", "excititor:redhat" }, + { ObservabilityEndpointTestsHelper.RetrievedAtField, now }, + { ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument() } + } + }); + + var consensus = database.GetCollection(VexMongoCollectionNames.Consensus); + consensus.InsertMany(new[] + { + ObservabilityEndpointTestsHelper.CreateConsensusDocument("c1", now, "affected"), + ObservabilityEndpointTestsHelper.CreateConsensusDocument("c2", now.AddMinutes(-5), "not_affected") + }); + + var stateRepository = scope.ServiceProvider.GetRequiredService(); + var state = new VexConnectorState( + "excititor:redhat", + now.AddMinutes(-5), + ImmutableArray.Empty, + ImmutableDictionary.Empty, + now.AddMinutes(-5), + 0, + now.AddMinutes(10), + null); + stateRepository.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult(); + } + + public void Dispose() + { + _factory.Dispose(); + _runner.Dispose(); + } + + private sealed class StubConnector : IVexConnector + { + public StubConnector(string id, VexProviderKind kind) + { + Id = id; + Kind = kind; + } + + public string Id { get; } + + public VexProviderKind Kind { get; } + + public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken) + => ValueTask.CompletedTask; + + public IAsyncEnumerable FetchAsync(VexConnectorContext context, CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); + + public ValueTask NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken) + => ValueTask.FromResult(new VexClaimBatch( + document, + ImmutableArray.Empty, + ImmutableDictionary.Empty)); + } +} + +internal static class ObservabilityEndpointTestsHelper +{ + public const string RetrievedAtField = "RetrievedAt"; + public const string MetadataField = "Metadata"; + + public static BsonDocument CreateConsensusDocument(string id, DateTime timestamp, string conflictStatus) + { + var conflicts = new BsonArray + { + new BsonDocument + { + { "ProviderId", "excititor:redhat" }, + { "Status", conflictStatus }, + { "DocumentDigest", Guid.NewGuid().ToString("n") } + } + }; + + return new BsonDocument + { + { "Id", id }, + { "VulnerabilityId", $"CVE-{id}" }, + { "Product", new BsonDocument { { "Key", $"pkg:{id}" }, { "Name", $"pkg-{id}" } } }, + { "Status", "affected" }, + { "CalculatedAt", timestamp }, + { "Conflicts", conflicts } + }; + } +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj index 31e1099d5..e06faf3d2 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj @@ -19,8 +19,9 @@ + - \ No newline at end of file + diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs index 2e43bfdb3..ccbbc704b 100644 --- a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/TestServiceOverrides.cs @@ -1,6 +1,7 @@ using System; -using System.Collections.Concurrent; -using System.Collections.Immutable; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; @@ -9,11 +10,12 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using StellaOps.Excititor.Core; using StellaOps.Excititor.Attestation.Verification; -using StellaOps.Excititor.Export; +using StellaOps.Excititor.Export; using StellaOps.Excititor.Storage.Mongo; using StellaOps.Excititor.WebService.Services; using MongoDB.Driver; -using StellaOps.Excititor.Attestation.Dsse; +using StellaOps.Excititor.Attestation.Dsse; +using StellaOps.Excititor.Attestation.Signing; namespace StellaOps.Excititor.WebService.Tests; @@ -24,23 +26,25 @@ internal static class TestServiceOverrides services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.RemoveAll(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.RemoveAll(); services.AddSingleton(); @@ -135,8 +139,8 @@ internal static class TestServiceOverrides => ValueTask.FromResult(0); } - private sealed class StubAttestationClient : IVexAttestationClient - { + private sealed class StubAttestationClient : IVexAttestationClient + { public ValueTask SignAsync(VexAttestationRequest request, CancellationToken cancellationToken) { var envelope = new DsseEnvelope( @@ -168,22 +172,34 @@ internal static class TestServiceOverrides } } - private sealed class StubConnectorStateRepository : IVexConnectorStateRepository - { - private readonly ConcurrentDictionary _states = new(StringComparer.Ordinal); - - public ValueTask GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + private sealed class StubConnectorStateRepository : IVexConnectorStateRepository + { + private readonly ConcurrentDictionary _states = new(StringComparer.Ordinal); + + public ValueTask GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null) { _states.TryGetValue(connectorId, out var state); return ValueTask.FromResult(state); } - public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) - { - _states[state.ConnectorId] = state; - return ValueTask.CompletedTask; - } - } + public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + _states[state.ConnectorId] = state; + return ValueTask.CompletedTask; + } + + public ValueTask> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + IReadOnlyCollection snapshot = _states.Values.ToList(); + return ValueTask.FromResult(snapshot); + } + } + + private sealed class StubSigner : IVexSigner + { + public ValueTask SignAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + => ValueTask.FromResult(new VexSignedPayload("stub-signature", "stub-key")); + } private sealed class StubIngestOrchestrator : IVexIngestOrchestrator { diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexGuardSchemaTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexGuardSchemaTests.cs new file mode 100644 index 000000000..392c393c0 --- /dev/null +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexGuardSchemaTests.cs @@ -0,0 +1,203 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using StellaOps.Aoc; + +namespace StellaOps.Excititor.WebService.Tests; + +public sealed class VexGuardSchemaTests +{ + private static readonly AocWriteGuard Guard = new(); + + [Fact] + public void CycloneDxFixture_CompliesWithGuard() + { + var result = ValidateCycloneDx(); + Assert.True(result.IsValid, DescribeViolations(result)); + } + + [Fact] + public void CsafFixture_CompliesWithGuard() + { + var result = ValidateCsaf(); + Assert.True(result.IsValid, DescribeViolations(result)); + } + + [Fact] + public void CycloneDxFixture_WithForbiddenField_ProducesErrAoc001() + { + var result = ValidateCycloneDx(node => node["severity"] = "critical"); + AssertViolation(result, "ERR_AOC_001", "/severity"); + } + + [Fact] + public void CycloneDxFixture_WithDerivedField_ProducesErrAoc006() + { + var result = ValidateCycloneDx(node => node["effective_owner"] = "security"); + AssertViolation(result, "ERR_AOC_006", "/effective_owner"); + } + + [Fact] + public void CycloneDxFixture_WithUnknownField_ProducesErrAoc007() + { + var result = ValidateCycloneDx(node => node["custom_field"] = 123); + AssertViolation(result, "ERR_AOC_007", "/custom_field"); + } + + [Fact] + public void CycloneDxFixture_WithSupersedes_RemainsValid() + { + var result = ValidateCycloneDx(node => node["supersedes"] = "digest:prev-cdx"); + Assert.True(result.IsValid, DescribeViolations(result)); + } + + [Fact] + public void CsafFixture_WithSupersedes_RemainsValid() + { + var result = ValidateCsaf(node => node["supersedes"] = "digest:prev-csaf"); + Assert.True(result.IsValid, DescribeViolations(result)); + } + + private static AocGuardResult ValidateCycloneDx(Action? mutate = null) + => ValidateFixture(CycloneDxRaw, mutate); + + private static AocGuardResult ValidateCsaf(Action? mutate = null) + => ValidateFixture(CsafRaw, mutate); + + private static AocGuardResult ValidateFixture(string json, Action? mutate) + { + var node = JsonNode.Parse(json)!.AsObject(); + mutate?.Invoke(node); + using var document = JsonDocument.Parse(node.ToJsonString()); + return Guard.Validate(document.RootElement); + } + + private static void AssertViolation(AocGuardResult result, string expectedCode, string expectedPath) + { + Assert.False(result.IsValid); + Assert.Contains(result.Violations, violation => + violation.ErrorCode == expectedCode && string.Equals(violation.Path, expectedPath, StringComparison.OrdinalIgnoreCase)); + } + + private static string DescribeViolations(AocGuardResult result) + => string.Join(", ", result.Violations.Select(v => $"{v.ErrorCode}:{v.Path}")); + + private const string CycloneDxRaw = """ + { + "tenant": "tests", + "source": { + "vendor": "cyclonedx", + "connector": "cdx", + "version": "1.0.0", + "stream": "vex-cyclonedx" + }, + "upstream": { + "upstream_id": "CDX-2025-0001", + "document_version": "2025.11.08", + "retrieved_at": "2025-11-08T00:00:00Z", + "content_hash": "sha256:cdx", + "signature": { "present": false } + }, + "content": { + "format": "CycloneDX", + "spec_version": "1.6", + "raw": { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:12345678-1234-5678-9abc-def012345678", + "version": 1, + "metadata": { + "timestamp": "2025-11-08T00:00:00Z", + "tools": [ + { "vendor": "stellaops", "name": "sample-vex-bot" } + ] + }, + "vulnerabilities": [ + { + "id": "CVE-2025-0001", + "analysis": { "state": "not_affected" }, + "ratings": [ + { "score": 0.0, "method": "cvssv3" } + ] + } + ] + } + }, + "linkset": { + "aliases": [], + "references": [], + "relationships": [], + "products": [], + "notes": {}, + "reconciled_from": [] + } + } + """; + + private const string CsafRaw = """ + { + "tenant": "tests", + "source": { + "vendor": "csaf", + "connector": "csaf-json", + "version": "1.2.3", + "stream": "vex-csaf" + }, + "upstream": { + "upstream_id": "CSAF-2025-0002", + "document_version": "2025.11.07", + "retrieved_at": "2025-11-08T01:10:00Z", + "content_hash": "sha256:csaf", + "signature": { "present": false } + }, + "content": { + "format": "CSAF", + "spec_version": "2.0", + "raw": { + "document": { + "category": "csaf_vex", + "title": "Sample CSAF VEX", + "tracking": { + "id": "CSAF-2025-0002", + "version": "1", + "current_release_date": "2025-11-07T00:00:00Z", + "initial_release_date": "2025-11-07T00:00:00Z", + "status": "final" + } + }, + "product_tree": { + "branches": [ + { + "name": "products", + "product": { + "name": "sample-product", + "product_id": "csaf-prod" + } + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2025-0002", + "product_status": { + "fixed": [ "csaf-prod" ] + }, + "threats": [ + { "category": "impact", "details": "none" } + ] + } + ] + } + }, + "linkset": { + "aliases": [], + "references": [], + "relationships": [], + "products": [], + "notes": {}, + "reconciled_from": [] + } + } + """; +} diff --git a/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexRawEndpointsTests.cs b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexRawEndpointsTests.cs new file mode 100644 index 000000000..ecfb376a0 --- /dev/null +++ b/src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/VexRawEndpointsTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using EphemeralMongo; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.Storage.Mongo; +using StellaOps.Excititor.WebService.Contracts; +using Xunit; + +namespace StellaOps.Excititor.WebService.Tests; + +public sealed class VexRawEndpointsTests : IDisposable +{ + private readonly IMongoRunner _runner; + private readonly TestWebApplicationFactory _factory; + + public VexRawEndpointsTests() + { + _runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true }); + + _factory = new TestWebApplicationFactory( + configureConfiguration: configuration => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString, + ["Excititor:Storage:Mongo:DatabaseName"] = "vex_raw_tests", + ["Excititor:Storage:Mongo:DefaultTenant"] = "tests", + }); + }, + configureServices: services => + { + TestServiceOverrides.Apply(services); + services.AddTestAuthentication(); + }); + } + + [Fact] + public async Task IngestListGetAndVerifyFlow() + { + using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin vex.read"); + client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tests"); + + var ingestRequest = BuildVexIngestRequest(); + var ingestResponse = await client.PostAsJsonAsync("/ingest/vex", ingestRequest); + ingestResponse.EnsureSuccessStatusCode(); + var ingestPayload = await ingestResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(ingestPayload); + Assert.True(ingestPayload!.Inserted); + + var getResponse = await client.GetAsync($"/vex/raw/{Uri.EscapeDataString(ingestPayload.Digest)}"); + getResponse.EnsureSuccessStatusCode(); + var record = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(record); + Assert.Equal(ingestPayload.Digest, record!.Digest); + + var listResponse = await client.GetAsync("/vex/raw?limit=5"); + listResponse.EnsureSuccessStatusCode(); + var listPayload = await listResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(listPayload); + Assert.Contains(listPayload!.Records, summary => summary.Digest == ingestPayload.Digest); + + var verifyRequest = new VexAocVerifyRequest(null, null, 10, null, null); + var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", verifyRequest); + verifyResponse.EnsureSuccessStatusCode(); + var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(verifyPayload); + Assert.True(verifyPayload!.Checked.Vex >= 1); + } + + private static VexIngestRequest BuildVexIngestRequest() + { + using var contentDocument = JsonDocument.Parse("{\"vex\":\"payload\"}"); + return new VexIngestRequest( + ProviderId: "excititor:test", + Source: new VexIngestSourceRequest("vendor:test", "connector:test", "1.0.0", "csaf"), + Upstream: new VexIngestUpstreamRequest( + SourceUri: "https://example.test/vex.json", + UpstreamId: "VEX-TEST-001", + DocumentVersion: "1", + RetrievedAt: DateTimeOffset.UtcNow, + ContentHash: "sha256:test", + Signature: new VexIngestSignatureRequest(false, null, null, null, null, null), + Provenance: new Dictionary()), + Content: new VexIngestContentRequest("csaf", "2.0", contentDocument.RootElement.Clone(), null), + Metadata: new Dictionary + { + ["source.vendor"] = "vendor:test" + }); + } + + public void Dispose() + { + _factory.Dispose(); + _runner.Dispose(); + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/TASKS.md b/src/ExportCenter/StellaOps.ExportCenter/TASKS.md index e813f7285..912eddab4 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/TASKS.md +++ b/src/ExportCenter/StellaOps.ExportCenter/TASKS.md @@ -11,7 +11,8 @@ | EXPORT-SVC-35-003 | TODO | Exporter Service Guild | EXPORT-SVC-35-002 | Deliver JSON adapters (`json:raw`, `json:policy`) with canonical normalization, redaction allowlists, compression, and manifest counts. | JSONL outputs deterministic; redaction enforced; unit/integration tests cover advisories/VEX/SBOM/findings. | | EXPORT-SVC-35-004 | TODO | Exporter Service Guild | EXPORT-SVC-35-002 | Build mirror (full) adapter producing filesystem layout, indexes, manifests, and README with download-only distribution. | Mirror bundle passes integration tests; indexes generated; manifest validated; docs cross-referenced. | | EXPORT-SVC-35-005 | TODO | Exporter Service Guild | EXPORT-SVC-35-003 | Implement manifest/provenance writer and KMS signing/attestation (detached + embedded) for bundle outputs. | `export.json`/`provenance.json` generated with hashes; signatures produced via KMS; verification test passes. | -| EXPORT-SVC-35-006 | TODO | Exporter Service Guild | EXPORT-SVC-35-001..005 | Expose Export API (profiles, runs, download, SSE updates) with audit logging, concurrency controls, and viewer/operator RBAC integration. | OpenAPI published; SSE stream validated; audit logs captured; rate limits enforced in tests. | +| EXPORT-SVC-35-006 | TODO | Exporter Service Guild | EXPORT-SVC-35-001..005 | Expose Export API (profiles, runs, download, SSE updates) with audit logging, concurrency controls, and viewer/operator RBAC integration. | OpenAPI published; SSE stream validated; audit logs captured; rate limits enforced in tests. | +| EXPORT-CRYPTO-90-001 `Crypto provider adoption` | TODO | Exporter Service Guild, Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Ensure manifest hashing, signing, and bundle encryption flows route through `ICryptoProviderRegistry`/`ICryptoHash` (see `docs/security/crypto-routing-audit-2025-11-07.md`) so RootPack deployments can select CryptoPro/PKCS#11 providers. | Bundle manifests, DSSE signing, and encryption keys respect profile ordering; integration tests cover default + `ru-offline`; docs updated with sovereign config instructions. | ## Sprint 36 – Trivy + Distribution | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs index 4bcb6a691..46f4b9023 100644 --- a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs @@ -19,6 +19,7 @@ using StellaOps.Findings.Ledger.Services; using StellaOps.Findings.Ledger.WebService.Contracts; using StellaOps.Findings.Ledger.WebService.Mappings; using StellaOps.Telemetry.Core; +using StellaOps.Findings.Ledger.Services.Security; const string LedgerWritePolicy = "ledger.events.write"; @@ -126,6 +127,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -156,10 +161,14 @@ app.UseAuthorization(); app.MapHealthChecks("/healthz"); app.MapPost("/vuln/ledger/events", async Task, Ok, ProblemHttpResult>> ( + HttpContext httpContext, + IConsoleCsrfValidator csrfValidator, LedgerEventRequest request, ILedgerEventWriteService writeService, CancellationToken cancellationToken) => { + csrfValidator.Validate(httpContext); + var draft = request.ToDraft(); var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false); return result.Status switch diff --git a/src/Findings/StellaOps.Findings.Ledger/Domain/LedgerChainIdGenerator.cs b/src/Findings/StellaOps.Findings.Ledger/Domain/LedgerChainIdGenerator.cs new file mode 100644 index 000000000..681939929 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Domain/LedgerChainIdGenerator.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; +using System.Text; + +namespace StellaOps.Findings.Ledger.Domain; + +public static class LedgerChainIdGenerator +{ + public static Guid FromTenantPolicy(string tenantId, string policyVersion) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(policyVersion); + + var normalized = $"{tenantId.Trim()}::{policyVersion.Trim()}"; + var bytes = Encoding.UTF8.GetBytes(normalized); + Span guidBytes = stackalloc byte[16]; + var hash = SHA256.HashData(bytes); + hash.AsSpan(0, 16).CopyTo(guidBytes); + return new Guid(guidBytes); + } +} diff --git a/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs b/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs index 5e7d34362..7afb671f2 100644 --- a/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs +++ b/src/Findings/StellaOps.Findings.Ledger/Options/LedgerServiceOptions.cs @@ -14,6 +14,8 @@ public sealed class LedgerServiceOptions public PolicyEngineOptions PolicyEngine { get; init; } = new(); + public AttachmentsOptions Attachments { get; init; } = new(); + public void Validate() { if (string.IsNullOrWhiteSpace(Database.ConnectionString)) @@ -47,6 +49,7 @@ public sealed class LedgerServiceOptions } PolicyEngine.Validate(); + Attachments.Validate(); } public sealed class DatabaseOptions @@ -143,4 +146,65 @@ public sealed class LedgerServiceOptions public TimeSpan EntryLifetime { get; set; } = DefaultCacheEntryLifetime; } + public sealed class AttachmentsOptions + { + private static readonly TimeSpan DefaultSignedUrlLifetime = TimeSpan.FromMinutes(15); + + public bool Enabled { get; set; } = true; + + public string EncryptionKey { get; set; } = string.Empty; + + public string SignedUrlBase { get; set; } = "https://evidence.local/attachments"; + + public string SignedUrlSecret { get; set; } = string.Empty; + + public TimeSpan SignedUrlLifetime { get; set; } = DefaultSignedUrlLifetime; + + public bool RequireConsoleCsrf { get; set; } = true; + + public string CsrfHeaderName { get; set; } = "x-stella-csrf"; + + public string CsrfSharedSecret { get; set; } = string.Empty; + + public void Validate() + { + if (!Enabled) + { + return; + } + + if (string.IsNullOrWhiteSpace(EncryptionKey)) + { + throw new InvalidOperationException("Attachments.EncryptionKey must be configured."); + } + + if (string.IsNullOrWhiteSpace(SignedUrlBase)) + { + throw new InvalidOperationException("Attachments.SignedUrlBase must be configured."); + } + + if (string.IsNullOrWhiteSpace(SignedUrlSecret)) + { + throw new InvalidOperationException("Attachments.SignedUrlSecret must be configured."); + } + + if (SignedUrlLifetime <= TimeSpan.Zero) + { + throw new InvalidOperationException("Attachments.SignedUrlLifetime must be greater than zero."); + } + + if (RequireConsoleCsrf) + { + if (string.IsNullOrWhiteSpace(CsrfHeaderName)) + { + throw new InvalidOperationException("Attachments.CsrfHeaderName must be configured when CSRF enforcement is enabled."); + } + + if (string.IsNullOrWhiteSpace(CsrfSharedSecret)) + { + throw new InvalidOperationException("Attachments.CsrfSharedSecret must be configured when CSRF enforcement is enabled."); + } + } + } + } } diff --git a/src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentEncryptionService.cs b/src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentEncryptionService.cs new file mode 100644 index 000000000..eb0eb7685 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentEncryptionService.cs @@ -0,0 +1,77 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Options; +using StellaOps.Findings.Ledger.Options; + +namespace StellaOps.Findings.Ledger.Services; + +public interface IAttachmentEncryptionService +{ + AttachmentEncryptionResult CreateEnvelope(NormalizedAttachment attachment, DateTimeOffset now); +} + +public sealed class AttachmentEncryptionService : IAttachmentEncryptionService +{ + private readonly byte[] masterKey; + private readonly LedgerServiceOptions.AttachmentsOptions options; + private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web); + + public AttachmentEncryptionService(IOptions optionsAccessor) + { + ArgumentNullException.ThrowIfNull(optionsAccessor); + options = optionsAccessor.Value.Attachments; + masterKey = Convert.FromBase64String(options.EncryptionKey); + if (masterKey.Length != 32) + { + throw new InvalidOperationException("Attachments.EncryptionKey must be a 256-bit (32-byte) key."); + } + } + + public AttachmentEncryptionResult CreateEnvelope(NormalizedAttachment attachment, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(attachment); + + var payload = new AttachmentEncryptionPayload( + attachment.AttachmentId, + attachment.Sha256, + attachment.ContentType, + attachment.SizeBytes, + attachment.Metadata); + + var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload, serializerOptions); + var nonce = RandomNumberGenerator.GetBytes(12); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[16]; + using var aes = new AesGcm(masterKey); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + return new AttachmentEncryptionResult( + Algorithm: "AES-256-GCM", + Ciphertext: Convert.ToBase64String(ciphertext), + Nonce: Convert.ToBase64String(nonce), + Tag: Convert.ToBase64String(tag), + ExpiresAt: now.Add(options.SignedUrlLifetime)); + } + + private sealed record AttachmentEncryptionPayload( + string AttachmentId, + string Sha256, + string ContentType, + long SizeBytes, + IReadOnlyDictionary? Metadata); +} + +public sealed record AttachmentEncryptionResult( + string Algorithm, + string Ciphertext, + string Nonce, + string Tag, + DateTimeOffset ExpiresAt); + +public sealed record NormalizedAttachment( + string AttachmentId, + string FileName, + string ContentType, + long SizeBytes, + string Sha256, + IReadOnlyDictionary? Metadata); diff --git a/src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentUrlSigner.cs b/src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentUrlSigner.cs new file mode 100644 index 000000000..9a6606348 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Services/Attachments/AttachmentUrlSigner.cs @@ -0,0 +1,51 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using StellaOps.Findings.Ledger.Options; + +namespace StellaOps.Findings.Ledger.Services; + +public interface IAttachmentUrlSigner +{ + AttachmentSignedUrl Sign(string attachmentId, DateTimeOffset now, TimeSpan lifetime); +} + +public sealed class AttachmentUrlSigner : IAttachmentUrlSigner +{ + private readonly LedgerServiceOptions.AttachmentsOptions options; + private readonly byte[] secretKey; + + public AttachmentUrlSigner(IOptions optionsAccessor) + { + ArgumentNullException.ThrowIfNull(optionsAccessor); + options = optionsAccessor.Value.Attachments; + secretKey = Encoding.UTF8.GetBytes(options.SignedUrlSecret ?? string.Empty); + if (secretKey.Length == 0) + { + throw new InvalidOperationException("Attachments.SignedUrlSecret must be configured."); + } + } + + public AttachmentSignedUrl Sign(string attachmentId, DateTimeOffset now, TimeSpan lifetime) + { + ArgumentException.ThrowIfNullOrWhiteSpace(attachmentId); + + var expires = now.Add(lifetime); + var expiresUnix = expires.ToUnixTimeSeconds(); + var payload = $"{attachmentId}|{expiresUnix}"; + using var hmac = new HMACSHA256(secretKey); + var signature = Base64UrlEncode(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload))); + + var baseUrl = options.SignedUrlBase.TrimEnd('/'); + var url = $"{baseUrl}/{Uri.EscapeDataString(attachmentId)}?exp={expiresUnix}&sig={signature}"; + return new AttachmentSignedUrl(new Uri(url, UriKind.Absolute), expires); + } + + private static string Base64UrlEncode(byte[] bytes) + => Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); +} + +public sealed record AttachmentSignedUrl(Uri Url, DateTimeOffset ExpiresAt); diff --git a/src/Findings/StellaOps.Findings.Ledger/Services/FindingWorkflowService.cs b/src/Findings/StellaOps.Findings.Ledger/Services/FindingWorkflowService.cs new file mode 100644 index 000000000..76119e486 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Services/FindingWorkflowService.cs @@ -0,0 +1,568 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Findings.Ledger.Domain; +using StellaOps.Findings.Ledger.Infrastructure; +using StellaOps.Findings.Ledger.Options; +using StellaOps.Findings.Ledger.Workflow; + +namespace StellaOps.Findings.Ledger.Services; + +public interface IFindingWorkflowService +{ + Task AssignAsync(AssignWorkflowRequest request, CancellationToken cancellationToken); + + Task CommentAsync(CommentWorkflowRequest request, CancellationToken cancellationToken); + + Task AcceptRiskAsync(AcceptRiskWorkflowRequest request, CancellationToken cancellationToken); + + Task TargetFixAsync(TargetFixWorkflowRequest request, CancellationToken cancellationToken); + + Task VerifyFixAsync(VerifyFixWorkflowRequest request, CancellationToken cancellationToken); + + Task ReopenAsync(ReopenWorkflowRequest request, CancellationToken cancellationToken); +} + +public sealed class FindingWorkflowService : IFindingWorkflowService +{ + private readonly ILedgerEventRepository repository; + private readonly ILedgerEventWriteService writeService; + private readonly IAttachmentEncryptionService attachmentEncryptionService; + private readonly IAttachmentUrlSigner attachmentUrlSigner; + private readonly LedgerServiceOptions.AttachmentsOptions attachmentOptions; + private readonly TimeProvider timeProvider; + private readonly ILogger logger; + + public FindingWorkflowService( + ILedgerEventRepository repository, + ILedgerEventWriteService writeService, + IAttachmentEncryptionService attachmentEncryptionService, + IAttachmentUrlSigner attachmentUrlSigner, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + this.writeService = writeService ?? throw new ArgumentNullException(nameof(writeService)); + this.attachmentEncryptionService = attachmentEncryptionService ?? throw new ArgumentNullException(nameof(attachmentEncryptionService)); + this.attachmentUrlSigner = attachmentUrlSigner ?? throw new ArgumentNullException(nameof(attachmentUrlSigner)); + attachmentOptions = options?.Value?.Attachments ?? throw new ArgumentNullException(nameof(options)); + this.timeProvider = timeProvider ?? TimeProvider.System; + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task AssignAsync(AssignWorkflowRequest request, CancellationToken cancellationToken) + { + var errors = ValidateBase(request); + if (request.Assignee is null || string.IsNullOrWhiteSpace(request.Assignee.Id)) + { + errors.Add("assignee_required"); + } + + if (errors.Count > 0) + { + return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); + } + + var payload = CreateBasePayload(request); + payload["action"] = "assign"; + payload["assignee"] = BuildAssigneeNode(request.Assignee); + AddComment(payload, request.Comment); + ApplyStatus(payload, request.Status); + ApplyAttachments(payload, request.Attachments); + + return AppendAsync(request, LedgerEventConstants.EventFindingAssignmentChanged, payload, cancellationToken); + } + + public Task CommentAsync(CommentWorkflowRequest request, CancellationToken cancellationToken) + { + var errors = ValidateBase(request); + if (string.IsNullOrWhiteSpace(request.Comment)) + { + errors.Add("comment_required"); + } + + if (errors.Count > 0) + { + return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); + } + + var payload = CreateBasePayload(request); + payload["action"] = "comment"; + payload["comment"] = request.Comment.Trim(); + ApplyAttachments(payload, request.Attachments); + return AppendAsync(request, LedgerEventConstants.EventFindingCommentAdded, payload, cancellationToken); + } + + public Task AcceptRiskAsync(AcceptRiskWorkflowRequest request, CancellationToken cancellationToken) + { + var errors = ValidateBase(request); + if (string.IsNullOrWhiteSpace(request.Justification)) + { + errors.Add("justification_required"); + } + + if (errors.Count > 0) + { + return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); + } + + var payload = CreateBasePayload(request); + payload["action"] = "accept-risk"; + payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "accepted_risk" : request.Status; + payload["justification"] = request.Justification.Trim(); + + if (!string.IsNullOrWhiteSpace(request.RiskOwner)) + { + payload["riskOwner"] = request.RiskOwner.Trim(); + } + + if (request.ExpiresAt is { } expiresAt) + { + payload["expiresAt"] = FormatTimestamp(expiresAt); + } + + ApplyAttachments(payload, request.Attachments); + return AppendAsync(request, LedgerEventConstants.EventFindingAcceptedRisk, payload, cancellationToken); + } + + public Task TargetFixAsync(TargetFixWorkflowRequest request, CancellationToken cancellationToken) + { + var errors = ValidateBase(request); + if (string.IsNullOrWhiteSpace(request.Summary)) + { + errors.Add("summary_required"); + } + + if (errors.Count > 0) + { + return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); + } + + var payload = CreateBasePayload(request); + payload["action"] = "target-fix"; + payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "in_progress" : request.Status; + payload["remediationPlan"] = request.Summary.Trim(); + + if (request.TargetCompletion is { } targetCompletion) + { + payload["targetCompletion"] = FormatTimestamp(targetCompletion); + } + + if (!string.IsNullOrWhiteSpace(request.TicketId) || request.TicketUrl is not null) + { + var ticket = new JsonObject(); + if (!string.IsNullOrWhiteSpace(request.TicketId)) + { + ticket["id"] = request.TicketId.Trim(); + } + + if (request.TicketUrl is not null) + { + ticket["url"] = request.TicketUrl.ToString(); + } + + payload["ticket"] = ticket; + } + + ApplyAttachments(payload, request.Attachments); + return AppendAsync(request, LedgerEventConstants.EventFindingRemediationPlanAdded, payload, cancellationToken); + } + + public Task VerifyFixAsync(VerifyFixWorkflowRequest request, CancellationToken cancellationToken) + { + var errors = ValidateBase(request); + if (errors.Count > 0) + { + return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); + } + + var payload = CreateBasePayload(request); + payload["action"] = "verify-fix"; + payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "verified" : request.Status; + + if (!string.IsNullOrWhiteSpace(request.Evidence)) + { + payload["evidence"] = request.Evidence.Trim(); + } + + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + payload["notes"] = request.Notes.Trim(); + } + + ApplyAttachments(payload, request.Attachments); + return AppendAsync(request, LedgerEventConstants.EventFindingStatusChanged, payload, cancellationToken); + } + + public Task ReopenAsync(ReopenWorkflowRequest request, CancellationToken cancellationToken) + { + var errors = ValidateBase(request); + if (errors.Count > 0) + { + return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); + } + + var payload = CreateBasePayload(request); + payload["action"] = "reopen"; + payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "affected" : request.Status; + if (!string.IsNullOrWhiteSpace(request.Reason)) + { + payload["reason"] = request.Reason.Trim(); + } + + ApplyAttachments(payload, request.Attachments); + return AppendAsync(request, LedgerEventConstants.EventFindingStatusChanged, payload, cancellationToken); + } + + private async Task AppendAsync( + WorkflowMutationRequest request, + string eventType, + JsonObject payload, + CancellationToken cancellationToken) + { + var chainContext = await ResolveChainContextAsync(request, cancellationToken).ConfigureAwait(false); + var eventId = request.EventId ?? Guid.CreateVersion7(); + var occurredAt = (request.OccurredAt ?? timeProvider.GetUtcNow()).ToUniversalTime(); + var recordedAt = timeProvider.GetUtcNow().ToUniversalTime(); + + var envelope = BuildCanonicalEnvelope(request, eventId, eventType, chainContext, occurredAt, recordedAt, payload); + + var draft = new LedgerEventDraft( + request.TenantId.Trim(), + chainContext.ChainId, + chainContext.Sequence, + eventId, + eventType, + request.PolicyVersion.Trim(), + request.FindingId.Trim(), + request.ArtifactId.Trim(), + SourceRunId: null, + request.Actor.Id.Trim(), + request.Actor.Type.Trim(), + occurredAt, + recordedAt, + payload, + envelope, + chainContext.PreviousHash); + + var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false); + if (result.Status == LedgerWriteStatus.Conflict) + { + logger.LogWarning( + "Workflow append conflict for tenant {Tenant} finding {Finding} ({EventType}).", + request.TenantId, + request.FindingId, + eventType); + } + + return result; + } + + private async Task ResolveChainContextAsync(WorkflowMutationRequest request, CancellationToken cancellationToken) + { + var chainId = request.ChainId ?? LedgerChainIdGenerator.FromTenantPolicy(request.TenantId, request.PolicyVersion); + var head = await repository.GetChainHeadAsync(request.TenantId, chainId, cancellationToken).ConfigureAwait(false); + var sequence = head is null ? 1 : head.SequenceNumber + 1; + var previousHash = head?.EventHash ?? LedgerEventConstants.EmptyHash; + return new ChainContext(chainId, sequence, previousHash); + } + + private static JsonObject BuildCanonicalEnvelope( + WorkflowMutationRequest request, + Guid eventId, + string eventType, + ChainContext chainContext, + DateTimeOffset occurredAt, + DateTimeOffset recordedAt, + JsonObject payload) + { + var eventObject = new JsonObject + { + ["id"] = eventId, + ["type"] = eventType, + ["tenant"] = request.TenantId.Trim(), + ["chainId"] = chainContext.ChainId, + ["sequence"] = chainContext.Sequence, + ["policyVersion"] = request.PolicyVersion.Trim(), + ["finding"] = new JsonObject + { + ["id"] = request.FindingId.Trim(), + ["artifactId"] = request.ArtifactId.Trim(), + ["vulnId"] = request.VulnerabilityId.Trim() + }, + ["actor"] = BuildActorNode(request.Actor), + ["occurredAt"] = FormatTimestamp(occurredAt), + ["recordedAt"] = FormatTimestamp(recordedAt), + ["payload"] = payload + }; + + return new JsonObject { ["event"] = eventObject }; + } + + private static JsonObject CreateBasePayload(WorkflowMutationRequest request) + { + var payload = new JsonObject(); + if (!string.IsNullOrWhiteSpace(request.PreviousStatus)) + { + payload["previousStatus"] = request.PreviousStatus.Trim(); + } + + return payload; + } + + private static JsonObject BuildActorNode(WorkflowActor actor) + { + var node = new JsonObject + { + ["id"] = actor.Id.Trim(), + ["type"] = actor.Type.Trim() + }; + + if (!string.IsNullOrWhiteSpace(actor.DisplayName)) + { + node["displayName"] = actor.DisplayName.Trim(); + } + + return node; + } + + private static JsonObject BuildAssigneeNode(WorkflowAssignee assignee) + { + var node = new JsonObject + { + ["id"] = assignee.Id.Trim(), + ["type"] = string.IsNullOrWhiteSpace(assignee.Type) ? "user" : assignee.Type.Trim() + }; + + if (!string.IsNullOrWhiteSpace(assignee.DisplayName)) + { + node["displayName"] = assignee.DisplayName.Trim(); + } + + return node; + } + + private static void AddComment(JsonObject payload, string? comment) + { + if (!string.IsNullOrWhiteSpace(comment)) + { + payload["comment"] = comment.Trim(); + } + } + + private static void ApplyStatus(JsonObject payload, string? status) + { + if (!string.IsNullOrWhiteSpace(status)) + { + payload["status"] = status.Trim(); + } + } + + private void ApplyAttachments(JsonObject payload, IReadOnlyList? attachments) + { + if (!attachmentOptions.Enabled) + { + return; + } + + var nodes = BuildAttachments(attachments); + if (nodes is not null) + { + payload["attachments"] = nodes; + } + } + + private JsonArray? BuildAttachments(IReadOnlyList? attachments) + { + if (attachments is null || attachments.Count == 0) + { + return null; + } + + var now = timeProvider.GetUtcNow(); + var normalized = attachments + .Where(static attachment => attachment is not null) + .Select(NormalizeAttachment) + .OrderBy(attachment => attachment.AttachmentId, StringComparer.Ordinal) + .ToArray(); + + if (normalized.Length == 0) + { + return null; + } + + var array = new JsonArray(); + foreach (var attachment in normalized) + { + var envelope = attachmentEncryptionService.CreateEnvelope(attachment, now); + var signed = attachmentUrlSigner.Sign(attachment.AttachmentId, now, attachmentOptions.SignedUrlLifetime); + var node = BuildAttachmentNode(attachment, envelope, signed); + array.Add(node); + } + + return array; + } + + private static JsonObject BuildAttachmentNode( + NormalizedAttachment attachment, + AttachmentEncryptionResult envelope, + AttachmentSignedUrl signedUrl) + { + var node = new JsonObject + { + ["id"] = attachment.AttachmentId, + ["fileName"] = attachment.FileName, + ["contentType"] = attachment.ContentType, + ["size"] = attachment.SizeBytes, + ["sha256"] = attachment.Sha256, + ["signedUrl"] = signedUrl.Url.ToString(), + ["urlExpiresAt"] = FormatTimestamp(signedUrl.ExpiresAt), + ["envelope"] = new JsonObject + { + ["algorithm"] = envelope.Algorithm, + ["ciphertext"] = envelope.Ciphertext, + ["nonce"] = envelope.Nonce, + ["tag"] = envelope.Tag, + ["expiresAt"] = FormatTimestamp(envelope.ExpiresAt) + } + }; + + if (attachment.Metadata is { Count: > 0 }) + { + var metadata = new JsonObject(); + foreach (var kvp in attachment.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + metadata[kvp.Key] = kvp.Value; + } + + node["metadata"] = metadata; + } + + return node; + } + + private static NormalizedAttachment NormalizeAttachment(WorkflowAttachmentMetadata attachment) + { + ArgumentNullException.ThrowIfNull(attachment); + + var metadata = attachment.Metadata is null + ? null + : new Dictionary( + attachment.Metadata + .Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null) + .ToDictionary( + kv => kv.Key.Trim(), + kv => kv.Value!.Trim(), + StringComparer.Ordinal), + StringComparer.Ordinal); + + return new NormalizedAttachment( + attachment.AttachmentId.Trim(), + attachment.FileName.Trim(), + attachment.ContentType.Trim(), + attachment.SizeBytes, + attachment.Sha256.Trim(), + metadata); + } + + private static void ApplyAttachmentsValidation(IReadOnlyList? attachments, ICollection errors) + { + if (attachments is null || attachments.Count == 0) + { + return; + } + + foreach (var attachment in attachments) + { + ValidateAttachment(attachment, errors); + } + } + + private static List ValidateBase(WorkflowMutationRequest request) + { + var errors = new List(); + if (request is null) + { + errors.Add("request_required"); + return errors; + } + + if (string.IsNullOrWhiteSpace(request.TenantId)) + { + errors.Add("tenant_id_required"); + } + + if (string.IsNullOrWhiteSpace(request.PolicyVersion)) + { + errors.Add("policy_version_required"); + } + + if (string.IsNullOrWhiteSpace(request.FindingId)) + { + errors.Add("finding_id_required"); + } + + if (string.IsNullOrWhiteSpace(request.ArtifactId)) + { + errors.Add("artifact_id_required"); + } + + if (string.IsNullOrWhiteSpace(request.VulnerabilityId)) + { + errors.Add("vuln_id_required"); + } + + if (request.Actor is null || string.IsNullOrWhiteSpace(request.Actor.Id)) + { + errors.Add("actor_id_required"); + } + else if (!LedgerEventConstants.SupportedActorTypes.Contains(request.Actor.Type)) + { + errors.Add($"actor_type_invalid:{request.Actor.Type}"); + } + + ApplyAttachmentsValidation(request.Attachments, errors); + + return errors; + } + + private static void ValidateAttachment(WorkflowAttachmentMetadata attachment, ICollection errors) + { + if (attachment is null) + { + errors.Add("attachment_invalid"); + return; + } + + if (string.IsNullOrWhiteSpace(attachment.AttachmentId)) + { + errors.Add("attachment_id_required"); + } + + if (string.IsNullOrWhiteSpace(attachment.FileName)) + { + errors.Add("attachment_name_required"); + } + + if (string.IsNullOrWhiteSpace(attachment.ContentType)) + { + errors.Add("attachment_content_type_required"); + } + + if (attachment.SizeBytes <= 0) + { + errors.Add("attachment_size_invalid"); + } + + if (string.IsNullOrWhiteSpace(attachment.Sha256) || attachment.Sha256.Length != 64) + { + errors.Add("attachment_sha256_invalid"); + } + } + + private static string FormatTimestamp(DateTimeOffset value) + => value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + + private readonly record struct ChainContext(Guid ChainId, long Sequence, string PreviousHash); +} diff --git a/src/Findings/StellaOps.Findings.Ledger/Services/Security/ConsoleCsrfValidator.cs b/src/Findings/StellaOps.Findings.Ledger/Services/Security/ConsoleCsrfValidator.cs new file mode 100644 index 000000000..23d8a5c0c --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Services/Security/ConsoleCsrfValidator.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using StellaOps.Findings.Ledger.Options; + +namespace StellaOps.Findings.Ledger.Services.Security; + +public interface IConsoleCsrfValidator +{ + void Validate(HttpContext httpContext); +} + +public sealed class ConsoleCsrfValidator : IConsoleCsrfValidator +{ + private readonly LedgerServiceOptions.AttachmentsOptions options; + private readonly byte[] sharedSecret; + + public ConsoleCsrfValidator(IOptions optionsAccessor) + { + ArgumentNullException.ThrowIfNull(optionsAccessor); + options = optionsAccessor.Value.Attachments; + sharedSecret = Encoding.UTF8.GetBytes(options.CsrfSharedSecret ?? string.Empty); + } + + public void Validate(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + if (!options.RequireConsoleCsrf) + { + return; + } + + if (sharedSecret.Length == 0) + { + throw new InvalidOperationException("Attachments.CsrfSharedSecret must be configured when enforcement is enabled."); + } + + if (!httpContext.Request.Headers.TryGetValue(options.CsrfHeaderName, out var headerValues) || headerValues.Count == 0) + { + throw new InvalidOperationException($"Missing {options.CsrfHeaderName} header."); + } + + var token = headerValues[0]; + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("CSRF token cannot be empty."); + } + + var expected = sharedSecret; + var provided = Encoding.UTF8.GetBytes(token.Trim()); + if (provided.Length != expected.Length || !CryptographicOperations.FixedTimeEquals(provided, expected)) + { + throw new InvalidOperationException("Invalid CSRF token."); + } + } +} diff --git a/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj b/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj index 45741ab3a..46e9155b9 100644 --- a/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj +++ b/src/Findings/StellaOps.Findings.Ledger/StellaOps.Findings.Ledger.csproj @@ -19,4 +19,8 @@ + + + + diff --git a/src/Findings/StellaOps.Findings.Ledger/TASKS.md b/src/Findings/StellaOps.Findings.Ledger/TASKS.md index d06275ec0..9e8e547c4 100644 --- a/src/Findings/StellaOps.Findings.Ledger/TASKS.md +++ b/src/Findings/StellaOps.Findings.Ledger/TASKS.md @@ -5,8 +5,8 @@ | LEDGER-29-002 | DONE (2025-11-03) | Findings Ledger Guild | LEDGER-29-001 | Implement ledger write API (`POST /vuln/ledger/events`) with validation, idempotency, hash chaining, and Merkle root computation job.
2025-11-03: Minimal web service scaffolded with canonical hashing, in-memory repository, Merkle scheduler stub, request/response contracts, and unit tests for hashing + conflict flows. | Events persisted with chained hashes; Merkle job emits anchors; unit/integration tests cover happy/pathological cases. | | LEDGER-29-003 | DONE (2025-11-03) | Findings Ledger Guild, Scheduler Guild | LEDGER-29-001 | Build projector worker that derives `findings_projection` rows from ledger events + policy determinations; ensure idempotent replay keyed by `(tenant,finding_id,policy_version)`. | Postgres-backed projector worker and reducers landed with replay checkpointing, fixtures, and tests. | | LEDGER-29-004 | DONE (2025-11-04) | Findings Ledger Guild, Policy Guild | LEDGER-29-003, POLICY-ENGINE-27-001 | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.
2025-11-04: Remote evaluation service wired via typed HttpClient, cache, and fallback inline evaluator; `/api/policy/eval/batch` documented; `policy_rationale` persisted with deterministic hashing; ledger tests `dotnet test src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/StellaOps.Findings.Ledger.Tests.csproj --no-restore` green. | Projector fetches determinations efficiently; rationale stored for UI; regression tests cover version switches. | -| LEDGER-29-005 | TODO | Findings Ledger Guild | LEDGER-29-003 | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. | API endpoints enforce business rules; attachments metadata stored; tests cover state machine transitions. | -| LEDGER-29-006 | TODO | Findings Ledger Guild, Security Guild | LEDGER-29-002 | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. | Attachments encrypted and accessible via signed URLs; security tests verify expiry + scope. | +| LEDGER-29-005 | DONE | Findings Ledger Guild | LEDGER-29-003 | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. | API endpoints enforce business rules; attachments metadata stored; tests cover state machine transitions. | +| LEDGER-29-006 | DONE | Findings Ledger Guild, Security Guild | LEDGER-29-002 | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. | Attachments encrypted and accessible via signed URLs; security tests verify expiry + scope. | | LEDGER-29-007 | TODO | Findings Ledger Guild, Observability Guild | LEDGER-29-002..005 | Instrument metrics (`ledger_write_latency`, `projection_lag_seconds`, `ledger_events_total`), structured logs, and Merkle anchoring alerts; publish dashboards. | Metrics/traces emitted; dashboards live; alert thresholds documented. | | LEDGER-29-008 | TODO | Findings Ledger Guild, QA Guild | LEDGER-29-002..005 | Develop unit/property/integration tests, replay/restore tooling, determinism harness, and load tests at 5M findings/tenant. | CI suite green; load tests documented; determinism harness proves stable projections. | | LEDGER-29-009 | TODO | Findings Ledger Guild, DevOps Guild | LEDGER-29-002..008 | Provide deployment manifests (Helm/Compose), backup/restore guidance, Merkle anchor externalization (optional), and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup/restore scripts recorded; offline kit includes seed data. | diff --git a/src/Findings/StellaOps.Findings.Ledger/Workflow/WorkflowMutationRequests.cs b/src/Findings/StellaOps.Findings.Ledger/Workflow/WorkflowMutationRequests.cs new file mode 100644 index 000000000..cc3f5042f --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger/Workflow/WorkflowMutationRequests.cs @@ -0,0 +1,92 @@ +namespace StellaOps.Findings.Ledger.Workflow; + +public abstract record WorkflowMutationRequest +{ + public required string TenantId { get; init; } + + public Guid? ChainId { get; init; } + + public required string PolicyVersion { get; init; } + + public required string FindingId { get; init; } + + public required string ArtifactId { get; init; } + + public required string VulnerabilityId { get; init; } + + public Guid? EventId { get; init; } + + public string? PreviousStatus { get; init; } + + public DateTimeOffset? OccurredAt { get; init; } + + public required WorkflowActor Actor { get; init; } + + public IReadOnlyList Attachments { get; init; } = Array.Empty(); +} + +public sealed record AssignWorkflowRequest : WorkflowMutationRequest +{ + public required WorkflowAssignee Assignee { get; init; } + + public string? Comment { get; init; } + + public string? Status { get; init; } +} + +public sealed record CommentWorkflowRequest : WorkflowMutationRequest +{ + public required string Comment { get; init; } +} + +public sealed record AcceptRiskWorkflowRequest : WorkflowMutationRequest +{ + public required string Justification { get; init; } + + public DateTimeOffset? ExpiresAt { get; init; } + + public string? RiskOwner { get; init; } + + public string? Status { get; init; } +} + +public sealed record TargetFixWorkflowRequest : WorkflowMutationRequest +{ + public required string Summary { get; init; } + + public DateTimeOffset? TargetCompletion { get; init; } + + public string? TicketId { get; init; } + + public Uri? TicketUrl { get; init; } + + public string? Status { get; init; } +} + +public sealed record VerifyFixWorkflowRequest : WorkflowMutationRequest +{ + public string? Evidence { get; init; } + + public string? Notes { get; init; } + + public string? Status { get; init; } +} + +public sealed record ReopenWorkflowRequest : WorkflowMutationRequest +{ + public string? Reason { get; init; } + + public string? Status { get; init; } +} + +public sealed record WorkflowActor(string Id, string Type, string? DisplayName = null); + +public sealed record WorkflowAssignee(string Id, string Type, string? DisplayName = null); + +public sealed record WorkflowAttachmentMetadata( + string AttachmentId, + string FileName, + string ContentType, + long SizeBytes, + string Sha256, + IReadOnlyDictionary? Metadata = null); diff --git a/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/FindingWorkflowServiceTests.cs b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/FindingWorkflowServiceTests.cs new file mode 100644 index 000000000..d60d3965a --- /dev/null +++ b/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/FindingWorkflowServiceTests.cs @@ -0,0 +1,182 @@ +using System.Linq; +using System.Text.Json.Nodes; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Findings.Ledger.Domain; +using StellaOps.Findings.Ledger.Infrastructure; +using StellaOps.Findings.Ledger.Infrastructure.InMemory; +using StellaOps.Findings.Ledger.Options; +using StellaOps.Findings.Ledger.Services; +using StellaOps.Findings.Ledger.Workflow; +using Xunit; + +namespace StellaOps.Findings.Ledger.Tests; + +public sealed class FindingWorkflowServiceTests +{ + private readonly InMemoryLedgerEventRepository _repository = new(); + private readonly IFindingWorkflowService _service; + private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2025-11-07T18:30:15Z")); + + public FindingWorkflowServiceTests() + { + var writeService = new LedgerEventWriteService( + _repository, + new NoOpMerkleScheduler(), + NullLogger.Instance); + + var options = Microsoft.Extensions.Options.Options.Create(new LedgerServiceOptions + { + Attachments = new LedgerServiceOptions.AttachmentsOptions + { + Enabled = true, + EncryptionKey = Convert.ToBase64String(Enumerable.Repeat((byte)0x22, 32).ToArray()), + SignedUrlBase = "https://evidence.local/attachments", + SignedUrlSecret = "signed-secret", + SignedUrlLifetime = TimeSpan.FromMinutes(5), + RequireConsoleCsrf = false + } + }); + + _service = new FindingWorkflowService( + _repository, + writeService, + new FakeAttachmentEncryptionService(), + new FakeAttachmentUrlSigner(), + options, + _timeProvider, + NullLogger.Instance); + } + + [Fact] + public async Task AssignAsync_WritesLedgerEventWithAssigneeAndAttachments() + { + var request = new AssignWorkflowRequest + { + TenantId = "tenant-east", + PolicyVersion = "sha256:policy@1", + FindingId = "finding|sha256:abc|CVE-2025-1234", + ArtifactId = "sha256:abc", + VulnerabilityId = "CVE-2025-1234", + Actor = new WorkflowActor("user:alice", "operator", "Alice"), + Assignee = new WorkflowAssignee("user:bob", "operator", "Bob"), + Comment = "Taking ownership", + Attachments = new[] + { + new WorkflowAttachmentMetadata( + "att-1", + "evidence.txt", + "text/plain", + 128, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + new Dictionary { ["scope"] = "triage" }) + } + }; + + var result = await _service.AssignAsync(request, CancellationToken.None); + + result.Status.Should().Be(LedgerWriteStatus.Success); + result.Record.Should().NotBeNull(); + var record = result.Record!; + record.EventType.Should().Be(LedgerEventConstants.EventFindingAssignmentChanged); + record.SequenceNumber.Should().Be(1); + record.ChainId.Should().NotBe(Guid.Empty); + record.OccurredAt.Should().Be(_timeProvider.UtcNow); + + var payload = ExtractPayload(record); + payload["action"]!.GetValue().Should().Be("assign"); + payload["assignee"]!.AsObject()["id"]!.GetValue().Should().Be("user:bob"); + var attachments = payload["attachments"]!.AsArray(); + attachments.Count.Should().Be(1); + var attachmentNode = attachments[0]!.AsObject(); + attachmentNode["fileName"]!.GetValue().Should().Be("evidence.txt"); + attachmentNode["signedUrl"]!.GetValue().Should().StartWith("https://signed.local/"); + attachmentNode["envelope"]!.AsObject()["algorithm"]!.GetValue().Should().Be("AES-256-GCM"); + } + + [Fact] + public async Task AcceptRiskAsync_DefaultsStatusAndPersistsJustification() + { + var request = new AcceptRiskWorkflowRequest + { + TenantId = "tenant-east", + PolicyVersion = "sha256:policy@1", + FindingId = "finding|sha256:abc|CVE-2025-1234", + ArtifactId = "sha256:abc", + VulnerabilityId = "CVE-2025-1234", + Actor = new WorkflowActor("user:alice", "operator"), + Justification = "Risk accepted for 30 days", + ExpiresAt = _timeProvider.UtcNow.AddDays(30), + RiskOwner = "ops-team" + }; + + var result = await _service.AcceptRiskAsync(request, CancellationToken.None); + + result.Status.Should().Be(LedgerWriteStatus.Success); + var payload = ExtractPayload(result.Record!); + payload["status"]!.GetValue().Should().Be("accepted_risk"); + payload["justification"]!.GetValue().Should().Be("Risk accepted for 30 days"); + payload["riskOwner"]!.GetValue().Should().Be("ops-team"); + payload.TryGetPropertyValue("attachments", out _).Should().BeFalse(); + } + + [Fact] + public async Task CommentAsync_ValidatesCommentPresence() + { + var request = new CommentWorkflowRequest + { + TenantId = "tenant-east", + PolicyVersion = "sha256:policy@1", + FindingId = "finding|sha256:abc|CVE-2025-1234", + ArtifactId = "sha256:abc", + VulnerabilityId = "CVE-2025-1234", + Actor = new WorkflowActor("user:alice", "operator"), + Comment = " " + }; + + var result = await _service.CommentAsync(request, CancellationToken.None); + + result.Status.Should().Be(LedgerWriteStatus.ValidationFailed); + result.Errors.Should().Contain("comment_required"); + } + + private static JsonObject ExtractPayload(LedgerEventRecord record) + { + var eventNode = record.EventBody["event"]?.AsObject() + ?? throw new InvalidOperationException("event node missing"); + return eventNode["payload"]?.AsObject() + ?? throw new InvalidOperationException("payload node missing"); + } + + private sealed class NoOpMerkleScheduler : IMerkleAnchorScheduler + { + public Task EnqueueAsync(LedgerEventRecord record, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class FakeTimeProvider : TimeProvider + { + public FakeTimeProvider(DateTimeOffset fixedTime) => UtcNow = fixedTime; + + public DateTimeOffset UtcNow { get; } + + public override DateTimeOffset GetUtcNow() => UtcNow; + } + + private sealed class FakeAttachmentEncryptionService : IAttachmentEncryptionService + { + public AttachmentEncryptionResult CreateEnvelope(NormalizedAttachment attachment, DateTimeOffset now) + => new( + Algorithm: "AES-256-GCM", + Ciphertext: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("ciphertext")), + Nonce: Convert.ToBase64String(new byte[] { 1, 2, 3 }), + Tag: Convert.ToBase64String(new byte[] { 4, 5, 6 }), + ExpiresAt: now.AddMinutes(5)); + } + + private sealed class FakeAttachmentUrlSigner : IAttachmentUrlSigner + { + public AttachmentSignedUrl Sign(string attachmentId, DateTimeOffset now, TimeSpan lifetime) + => new(new Uri($"https://signed.local/{attachmentId}?sig=fake"), now.Add(lifetime)); + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Compilation/PolicyComplexityAnalyzer.cs b/src/Policy/StellaOps.Policy.Engine/Compilation/PolicyComplexityAnalyzer.cs new file mode 100644 index 000000000..23d195d53 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Compilation/PolicyComplexityAnalyzer.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Immutable; + +namespace StellaOps.Policy.Engine.Compilation; + +/// +/// Computes deterministic complexity metrics for compiled policies. +/// +internal sealed class PolicyComplexityAnalyzer +{ + public PolicyComplexityReport Analyze(PolicyIrDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + var metrics = new ComplexityMetrics(); + metrics.RuleCount = document.Rules.IsDefault ? 0 : document.Rules.Length; + + VisitMetadata(document.Metadata.Values, metrics); + VisitMetadata(document.Settings.Values, metrics); + VisitProfiles(document.Profiles, metrics); + + if (!document.Rules.IsDefaultOrEmpty) + { + foreach (var rule in document.Rules) + { + metrics.ConditionCount++; + VisitExpression(rule.When, metrics, depth: 0); + + VisitActions(rule.ThenActions, metrics); + VisitActions(rule.ElseActions, metrics); + } + } + + var score = CalculateScore(metrics); + var roundedScore = Math.Round(score, 3, MidpointRounding.AwayFromZero); + + return new PolicyComplexityReport( + roundedScore, + metrics.RuleCount, + metrics.ActionCount, + metrics.ExpressionCount, + metrics.InvocationCount, + metrics.MemberAccessCount, + metrics.IdentifierCount, + metrics.LiteralCount, + metrics.MaxDepth, + metrics.ProfileCount, + metrics.ProfileBindings, + metrics.ConditionCount, + metrics.ListItems); + } + + private static void VisitProfiles(ImmutableArray profiles, ComplexityMetrics metrics) + { + if (profiles.IsDefaultOrEmpty) + { + return; + } + + foreach (var profile in profiles) + { + metrics.ProfileCount++; + + if (!profile.Maps.IsDefaultOrEmpty) + { + foreach (var map in profile.Maps) + { + if (map.Entries.IsDefaultOrEmpty) + { + continue; + } + + foreach (var entry in map.Entries) + { + metrics.ProfileBindings++; + metrics.LiteralCount++; // weight values contribute to literal count + } + } + } + + if (!profile.Environments.IsDefaultOrEmpty) + { + foreach (var environment in profile.Environments) + { + if (environment.Entries.IsDefaultOrEmpty) + { + continue; + } + + foreach (var entry in environment.Entries) + { + metrics.ProfileBindings++; + metrics.ConditionCount++; + VisitExpression(entry.Condition, metrics, depth: 0); + } + } + } + + if (!profile.Scalars.IsDefaultOrEmpty) + { + foreach (var scalar in profile.Scalars) + { + metrics.ProfileBindings++; + VisitLiteral(scalar.Value, metrics); + } + } + } + } + + private static void VisitMetadata(IEnumerable literals, ComplexityMetrics metrics) + { + foreach (var literal in literals) + { + VisitLiteral(literal, metrics); + } + } + + private static void VisitLiteral(PolicyIrLiteral literal, ComplexityMetrics metrics) + { + switch (literal) + { + case PolicyIrListLiteral list when !list.Items.IsDefaultOrEmpty: + foreach (var item in list.Items) + { + VisitLiteral(item, metrics); + } + break; + } + + metrics.LiteralCount++; + } + + private static void VisitActions(ImmutableArray actions, ComplexityMetrics metrics) + { + if (actions.IsDefaultOrEmpty) + { + return; + } + + foreach (var action in actions) + { + metrics.ActionCount++; + switch (action) + { + case PolicyIrAssignmentAction assignment: + VisitExpression(assignment.Value, metrics, depth: 0); + break; + case PolicyIrAnnotateAction annotate: + VisitExpression(annotate.Value, metrics, depth: 0); + break; + case PolicyIrIgnoreAction ignore when ignore.Until is not null: + VisitExpression(ignore.Until, metrics, depth: 0); + break; + case PolicyIrEscalateAction escalate: + VisitExpression(escalate.To, metrics, depth: 0); + VisitExpression(escalate.When, metrics, depth: 0); + break; + case PolicyIrRequireVexAction require when !require.Conditions.IsEmpty: + foreach (var condition in require.Conditions.Values) + { + VisitExpression(condition, metrics, depth: 0); + } + break; + case PolicyIrWarnAction warn when warn.Message is not null: + VisitExpression(warn.Message, metrics, depth: 0); + break; + case PolicyIrDeferAction defer when defer.Until is not null: + VisitExpression(defer.Until, metrics, depth: 0); + break; + } + } + } + + private static void VisitExpression(PolicyExpression? expression, ComplexityMetrics metrics, int depth) + { + if (expression is null) + { + return; + } + + metrics.ExpressionCount++; + var currentDepth = depth + 1; + if (currentDepth > metrics.MaxDepth) + { + metrics.MaxDepth = currentDepth; + } + + switch (expression) + { + case PolicyLiteralExpression: + metrics.LiteralCount++; + break; + case PolicyListExpression listExpression: + if (!listExpression.Items.IsDefaultOrEmpty) + { + foreach (var item in listExpression.Items) + { + metrics.ListItems++; + VisitExpression(item, metrics, currentDepth); + } + } + break; + case PolicyIdentifierExpression: + metrics.IdentifierCount++; + break; + case PolicyMemberAccessExpression member: + metrics.MemberAccessCount++; + VisitExpression(member.Target, metrics, currentDepth); + break; + case PolicyInvocationExpression invocation: + metrics.InvocationCount++; + VisitExpression(invocation.Target, metrics, currentDepth); + if (!invocation.Arguments.IsDefaultOrEmpty) + { + foreach (var argument in invocation.Arguments) + { + VisitExpression(argument, metrics, currentDepth); + } + } + break; + case PolicyIndexerExpression indexer: + VisitExpression(indexer.Target, metrics, currentDepth); + VisitExpression(indexer.Index, metrics, currentDepth); + break; + case PolicyUnaryExpression unary: + VisitExpression(unary.Operand, metrics, currentDepth); + break; + case PolicyBinaryExpression binary: + VisitExpression(binary.Left, metrics, currentDepth); + VisitExpression(binary.Right, metrics, currentDepth); + break; + default: + break; + } + } + + private static double CalculateScore(ComplexityMetrics metrics) + { + return metrics.RuleCount * 5d + + metrics.ActionCount * 1.5d + + metrics.ExpressionCount * 0.75d + + metrics.InvocationCount * 1.5d + + metrics.MemberAccessCount * 1.0d + + metrics.IdentifierCount * 0.5d + + metrics.LiteralCount * 0.25d + + metrics.ProfileBindings * 0.5d + + metrics.ConditionCount * 1.25d + + metrics.MaxDepth * 2d + + metrics.ListItems * 0.25d; + } + + private sealed class ComplexityMetrics + { + public int RuleCount; + public int ActionCount; + public int ExpressionCount; + public int InvocationCount; + public int MemberAccessCount; + public int IdentifierCount; + public int LiteralCount; + public int ProfileCount; + public int ProfileBindings; + public int ConditionCount; + public int MaxDepth; + public int ListItems; + } +} + +internal sealed record PolicyComplexityReport( + double Score, + int RuleCount, + int ActionCount, + int ExpressionCount, + int InvocationCount, + int MemberAccessCount, + int IdentifierCount, + int LiteralCount, + int MaxExpressionDepth, + int ProfileCount, + int ProfileBindingCount, + int ConditionCount, + int ListItemCount); diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyCompilationEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyCompilationEndpoints.cs index f2a12e787..ab3ec34ac 100644 --- a/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyCompilationEndpoints.cs +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyCompilationEndpoints.cs @@ -1,107 +1,150 @@ -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using StellaOps.Policy; -using StellaOps.Policy.Engine.Services; -using System.Collections.Immutable; - -namespace StellaOps.Policy.Engine.Endpoints; - -internal static class PolicyCompilationEndpoints -{ - private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile"; - - public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost(CompileRoute, CompilePolicy) - .WithName("CompilePolicy") - .WithSummary("Compile and lint a policy DSL document.") - .WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .RequireAuthorization(); // scopes enforced by policy middleware. - - return endpoints; - } - - private static IResult CompilePolicy( - [FromRoute] string policyId, - [FromRoute] int version, - [FromBody] PolicyCompileRequest request, - PolicyCompilationService compilationService) - { - if (request is null) - { - return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version)); - } - - var result = compilationService.Compile(request); - if (!result.Success) - { - return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics)); - } - - var response = new PolicyCompileResponse( - result.Digest!, - result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary.Empty), - ConvertDiagnostics(result.Diagnostics)); - return Results.Ok(response); - } - - private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray? diagnostics = null) - { - var problem = new PolicyProblemDetails - { - Code = code, - Title = "Policy compilation error", - Detail = message, - PolicyId = policyId, - PolicyVersion = version - }; - - if (diagnostics is { Length: > 0 } diag) - { - problem.Diagnostics = diag; - } - - return problem; - } - - private static ImmutableArray ConvertDiagnostics(ImmutableArray issues) - { - if (issues.IsDefaultOrEmpty) - { - return ImmutableArray.Empty; - } - - var builder = ImmutableArray.CreateBuilder(issues.Length); - foreach (var issue in issues) - { - if (issue.Severity != PolicyIssueSeverity.Warning) - { - continue; - } - - builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path)); - } - - return builder.ToImmutable(); - } - - private sealed class PolicyProblemDetails : ProblemDetails - { - public string Code { get; set; } = "ERR_POL_001"; - - public string? PolicyId { get; set; } - - public int PolicyVersion { get; set; } - - public ImmutableArray Diagnostics { get; set; } = ImmutableArray.Empty; - } -} - -internal sealed record PolicyCompileResponse( - string Digest, - PolicyCompilationStatistics Statistics, - ImmutableArray Warnings); - -internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path); +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using StellaOps.Policy; +using StellaOps.Policy.Engine.Compilation; +using StellaOps.Policy.Engine.Services; +using System.Collections.Immutable; + +namespace StellaOps.Policy.Engine.Endpoints; + +internal static class PolicyCompilationEndpoints +{ + private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile"; + + public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost(CompileRoute, CompilePolicy) + .WithName("CompilePolicy") + .WithSummary("Compile and lint a policy DSL document.") + .WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .RequireAuthorization(); // scopes enforced by policy middleware. + + return endpoints; + } + + private static IResult CompilePolicy( + [FromRoute] string policyId, + [FromRoute] int version, + [FromBody] PolicyCompileRequest request, + PolicyCompilationService compilationService) + { + if (request is null) + { + return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version)); + } + + var result = compilationService.Compile(request); + if (!result.Success) + { + return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics)); + } + + var response = new PolicyCompileResponse( + result.Digest!, + result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary.Empty), + MapComplexity(result.Complexity), + result.DurationMilliseconds, + ConvertDiagnostics(result.Diagnostics)); + return Results.Ok(response); + } + + private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray? diagnostics = null) + { + var problem = new PolicyProblemDetails + { + Code = code, + Title = "Policy compilation error", + Detail = message, + PolicyId = policyId, + PolicyVersion = version + }; + + if (diagnostics is { Length: > 0 } diag) + { + problem.Diagnostics = diag; + } + + return problem; + } + + private static ImmutableArray ConvertDiagnostics(ImmutableArray issues) + { + if (issues.IsDefaultOrEmpty) + { + return ImmutableArray.Empty; + } + + var builder = ImmutableArray.CreateBuilder(issues.Length); + foreach (var issue in issues) + { + if (issue.Severity != PolicyIssueSeverity.Warning) + { + continue; + } + + builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path)); + } + + return builder.ToImmutable(); + } + + private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report) + { + if (report is null) + { + return null; + } + + return new PolicyComplexityReportDto( + report.Score, + report.RuleCount, + report.ActionCount, + report.ExpressionCount, + report.InvocationCount, + report.MemberAccessCount, + report.IdentifierCount, + report.LiteralCount, + report.MaxExpressionDepth, + report.ProfileCount, + report.ProfileBindingCount, + report.ConditionCount, + report.ListItemCount); + } + + private sealed class PolicyProblemDetails : ProblemDetails + { + public string Code { get; set; } = "ERR_POL_001"; + + public string? PolicyId { get; set; } + + public int PolicyVersion { get; set; } + + public ImmutableArray Diagnostics { get; set; } = ImmutableArray.Empty; + } +} + +internal sealed record PolicyCompileResponse( + string Digest, + PolicyCompilationStatistics Statistics, + PolicyComplexityReportDto? Complexity, + long DurationMilliseconds, + ImmutableArray Warnings); + +internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path); + +internal sealed record PolicyComplexityReportDto( + double Score, + int RuleCount, + int ActionCount, + int ExpressionCount, + int InvocationCount, + int MemberAccessCount, + int IdentifierCount, + int LiteralCount, + int MaxExpressionDepth, + int ProfileCount, + int ProfileBindingCount, + int ConditionCount, + int ListItemCount); diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyPackEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyPackEndpoints.cs index f71f147a7..2ff11f6ae 100644 --- a/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyPackEndpoints.cs +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyPackEndpoints.cs @@ -1,267 +1,274 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using StellaOps.Auth.Abstractions; -using StellaOps.Policy.Engine.Domain; -using StellaOps.Policy.Engine.Services; - -namespace StellaOps.Policy.Engine.Endpoints; - -internal static class PolicyPackEndpoints -{ - public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints) - { - var group = endpoints.MapGroup("/api/policy/packs") - .RequireAuthorization() - .WithTags("Policy Packs"); - - group.MapPost(string.Empty, CreatePack) - .WithName("CreatePolicyPack") - .WithSummary("Create a new policy pack container.") - .Produces(StatusCodes.Status201Created); - - group.MapGet(string.Empty, ListPacks) - .WithName("ListPolicyPacks") - .WithSummary("List policy packs for the current tenant.") - .Produces>(StatusCodes.Status200OK); - - group.MapPost("/{packId}/revisions", CreateRevision) - .WithName("CreatePolicyRevision") - .WithSummary("Create or update policy revision metadata.") - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status400BadRequest); - - group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision) - .WithName("ActivatePolicyRevision") - .WithSummary("Activate an approved policy revision, enforcing two-person approval when required.") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status202Accepted) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status404NotFound); - - return endpoints; - } - - private static async Task CreatePack( - HttpContext context, - [FromBody] CreatePolicyPackRequest request, - IPolicyPackRepository repository, - CancellationToken cancellationToken) - { - var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit); - if (scopeResult is not null) - { - return scopeResult; - } - - if (request is null) - { - return Results.BadRequest(new ProblemDetails - { - Title = "Invalid request", - Detail = "Request body is required.", - Status = StatusCodes.Status400BadRequest - }); - } - - var packId = string.IsNullOrWhiteSpace(request.PackId) - ? $"pack-{Guid.NewGuid():n}" - : request.PackId.Trim(); - - var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false); - var dto = PolicyPackMapper.ToDto(pack); - return Results.Created($"/api/policy/packs/{dto.PackId}", dto); - } - - private static async Task ListPacks( - HttpContext context, - IPolicyPackRepository repository, - CancellationToken cancellationToken) - { - var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); - if (scopeResult is not null) - { - return scopeResult; - } - - var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false); - var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray(); - return Results.Ok(summaries); - } - - private static async Task CreateRevision( - HttpContext context, - [FromRoute] string packId, - [FromBody] CreatePolicyRevisionRequest request, - IPolicyPackRepository repository, - CancellationToken cancellationToken) - { - var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit); - if (scopeResult is not null) - { - return scopeResult; - } - - if (request is null) - { - return Results.BadRequest(new ProblemDetails - { - Title = "Invalid request", - Detail = "Request body is required.", - Status = StatusCodes.Status400BadRequest - }); - } - - if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved)) - { - return Results.BadRequest(new ProblemDetails - { - Title = "Invalid status", - Detail = "Only Draft or Approved statuses are supported for new revisions.", - Status = StatusCodes.Status400BadRequest - }); - } - - var revision = await repository.UpsertRevisionAsync( - packId, - request.Version ?? 0, - request.RequiresTwoPersonApproval, - request.InitialStatus, - cancellationToken).ConfigureAwait(false); - - return Results.Created( - $"/api/policy/packs/{packId}/revisions/{revision.Version}", - PolicyPackMapper.ToDto(packId, revision)); - } - - private static async Task ActivateRevision( - HttpContext context, - [FromRoute] string packId, - [FromRoute] int version, - [FromBody] ActivatePolicyRevisionRequest request, - IPolicyPackRepository repository, - CancellationToken cancellationToken) - { - var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate); - if (scopeResult is not null) - { - return scopeResult; - } - - if (request is null) - { - return Results.BadRequest(new ProblemDetails - { - Title = "Invalid request", - Detail = "Request body is required.", - Status = StatusCodes.Status400BadRequest - }); - } - - var actorId = ResolveActorId(context); - if (actorId is null) - { - return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized); - } - - var result = await repository.RecordActivationAsync( - packId, - version, - actorId, - DateTimeOffset.UtcNow, - request.Comment, - cancellationToken).ConfigureAwait(false); - - return result.Status switch - { - PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails - { - Title = "Policy pack not found", - Status = StatusCodes.Status404NotFound - }), - PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails - { - Title = "Policy revision not found", - Status = StatusCodes.Status404NotFound - }), - PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails - { - Title = "Revision not approved", - Detail = "Only approved revisions may be activated.", - Status = StatusCodes.Status400BadRequest - }), - PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails - { - Title = "Approval already recorded", - Detail = "This approver has already approved activation.", - Status = StatusCodes.Status400BadRequest - }), - PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted( - $"/api/policy/packs/{packId}/revisions/{version}", - new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))), - PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))), - PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))), - _ => Results.BadRequest(new ProblemDetails - { - Title = "Activation failed", - Detail = "Unknown activation result.", - Status = StatusCodes.Status400BadRequest - }) - }; - } - - private static string? ResolveActorId(HttpContext context) - { - var user = context.User; - var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value - ?? user?.FindFirst(ClaimTypes.Upn)?.Value - ?? user?.FindFirst("sub")?.Value; - - if (!string.IsNullOrWhiteSpace(actor)) - { - return actor; - } - - if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header)) - { - return header.ToString(); - } - - return null; - } -} - -internal static class PolicyPackMapper -{ - public static PolicyPackDto ToDto(PolicyPackRecord record) - => new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray()); - - public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record) - => new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray()); - - public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision) - => new( - packId, - revision.Version, - revision.Status.ToString(), - revision.RequiresTwoPersonApproval, - revision.CreatedAt, - revision.ActivatedAt, - revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray()); -} - -internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName); - -internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList Revisions); - -internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList Versions); - -internal sealed record CreatePolicyRevisionRequest(int? Version, bool RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved); - -internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList Approvals); - -internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment); - -internal sealed record ActivatePolicyRevisionRequest(string? Comment); - -internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision); +using System.Security.Claims; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.Abstractions; +using StellaOps.Policy.Engine.Domain; +using StellaOps.Policy.Engine.Services; + +namespace StellaOps.Policy.Engine.Endpoints; + +internal static class PolicyPackEndpoints +{ + public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/policy/packs") + .RequireAuthorization() + .WithTags("Policy Packs"); + + group.MapPost(string.Empty, CreatePack) + .WithName("CreatePolicyPack") + .WithSummary("Create a new policy pack container.") + .Produces(StatusCodes.Status201Created); + + group.MapGet(string.Empty, ListPacks) + .WithName("ListPolicyPacks") + .WithSummary("List policy packs for the current tenant.") + .Produces>(StatusCodes.Status200OK); + + group.MapPost("/{packId}/revisions", CreateRevision) + .WithName("CreatePolicyRevision") + .WithSummary("Create or update policy revision metadata.") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest); + + group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision) + .WithName("ActivatePolicyRevision") + .WithSummary("Activate an approved policy revision, enforcing two-person approval when required.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + return endpoints; + } + + private static async Task CreatePack( + HttpContext context, + [FromBody] CreatePolicyPackRequest request, + IPolicyPackRepository repository, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit); + if (scopeResult is not null) + { + return scopeResult; + } + + if (request is null) + { + return Results.BadRequest(new ProblemDetails + { + Title = "Invalid request", + Detail = "Request body is required.", + Status = StatusCodes.Status400BadRequest + }); + } + + var packId = string.IsNullOrWhiteSpace(request.PackId) + ? $"pack-{Guid.NewGuid():n}" + : request.PackId.Trim(); + + var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false); + var dto = PolicyPackMapper.ToDto(pack); + return Results.Created($"/api/policy/packs/{dto.PackId}", dto); + } + + private static async Task ListPacks( + HttpContext context, + IPolicyPackRepository repository, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead); + if (scopeResult is not null) + { + return scopeResult; + } + + var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false); + var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray(); + return Results.Ok(summaries); + } + + private static async Task CreateRevision( + HttpContext context, + [FromRoute] string packId, + [FromBody] CreatePolicyRevisionRequest request, + IPolicyPackRepository repository, + IPolicyActivationSettings activationSettings, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit); + if (scopeResult is not null) + { + return scopeResult; + } + + if (request is null) + { + return Results.BadRequest(new ProblemDetails + { + Title = "Invalid request", + Detail = "Request body is required.", + Status = StatusCodes.Status400BadRequest + }); + } + + if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved)) + { + return Results.BadRequest(new ProblemDetails + { + Title = "Invalid status", + Detail = "Only Draft or Approved statuses are supported for new revisions.", + Status = StatusCodes.Status400BadRequest + }); + } + + var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval); + + var revision = await repository.UpsertRevisionAsync( + packId, + request.Version ?? 0, + requiresTwoPersonApproval, + request.InitialStatus, + cancellationToken).ConfigureAwait(false); + + return Results.Created( + $"/api/policy/packs/{packId}/revisions/{revision.Version}", + PolicyPackMapper.ToDto(packId, revision)); + } + + private static async Task ActivateRevision( + HttpContext context, + [FromRoute] string packId, + [FromRoute] int version, + [FromBody] ActivatePolicyRevisionRequest request, + IPolicyPackRepository repository, + IPolicyActivationAuditor auditor, + CancellationToken cancellationToken) + { + var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate); + if (scopeResult is not null) + { + return scopeResult; + } + + if (request is null) + { + return Results.BadRequest(new ProblemDetails + { + Title = "Invalid request", + Detail = "Request body is required.", + Status = StatusCodes.Status400BadRequest + }); + } + + var actorId = ResolveActorId(context); + if (actorId is null) + { + return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized); + } + + var result = await repository.RecordActivationAsync( + packId, + version, + actorId, + DateTimeOffset.UtcNow, + request.Comment, + cancellationToken).ConfigureAwait(false); + + var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value; + auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment); + + return result.Status switch + { + PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails + { + Title = "Policy pack not found", + Status = StatusCodes.Status404NotFound + }), + PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails + { + Title = "Policy revision not found", + Status = StatusCodes.Status404NotFound + }), + PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails + { + Title = "Revision not approved", + Detail = "Only approved revisions may be activated.", + Status = StatusCodes.Status400BadRequest + }), + PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails + { + Title = "Approval already recorded", + Detail = "This approver has already approved activation.", + Status = StatusCodes.Status400BadRequest + }), + PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted( + $"/api/policy/packs/{packId}/revisions/{version}", + new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))), + PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))), + PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))), + _ => Results.BadRequest(new ProblemDetails + { + Title = "Activation failed", + Detail = "Unknown activation result.", + Status = StatusCodes.Status400BadRequest + }) + }; + } + + private static string? ResolveActorId(HttpContext context) + { + var user = context.User; + var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? user?.FindFirst(ClaimTypes.Upn)?.Value + ?? user?.FindFirst("sub")?.Value; + + if (!string.IsNullOrWhiteSpace(actor)) + { + return actor; + } + + if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header)) + { + return header.ToString(); + } + + return null; + } +} + +internal static class PolicyPackMapper +{ + public static PolicyPackDto ToDto(PolicyPackRecord record) + => new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray()); + + public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record) + => new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray()); + + public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision) + => new( + packId, + revision.Version, + revision.Status.ToString(), + revision.RequiresTwoPersonApproval, + revision.CreatedAt, + revision.ActivatedAt, + revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray()); +} + +internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName); + +internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList Revisions); + +internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList Versions); + +internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved); + +internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList Approvals); + +internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment); + +internal sealed record ActivatePolicyRevisionRequest(string? Comment); + +internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision); diff --git a/src/Policy/StellaOps.Policy.Engine/Options/PolicyEngineOptions.cs b/src/Policy/StellaOps.Policy.Engine/Options/PolicyEngineOptions.cs index f5210eb10..6389a36c4 100644 --- a/src/Policy/StellaOps.Policy.Engine/Options/PolicyEngineOptions.cs +++ b/src/Policy/StellaOps.Policy.Engine/Options/PolicyEngineOptions.cs @@ -1,168 +1,227 @@ -using System.Collections.ObjectModel; -using StellaOps.Auth.Abstractions; - -namespace StellaOps.Policy.Engine.Options; - -/// -/// Root configuration for the Policy Engine host. -/// -public sealed class PolicyEngineOptions -{ - public const string SectionName = "PolicyEngine"; - - public PolicyEngineAuthorityOptions Authority { get; } = new(); - - public PolicyEngineStorageOptions Storage { get; } = new(); - - public PolicyEngineWorkerOptions Workers { get; } = new(); - - public PolicyEngineResourceServerOptions ResourceServer { get; } = new(); - - public void Validate() - { - Authority.Validate(); - Storage.Validate(); - Workers.Validate(); - ResourceServer.Validate(); - } -} - -public sealed class PolicyEngineAuthorityOptions -{ - public bool Enabled { get; set; } = true; - - public string Issuer { get; set; } = "https://authority.stella-ops.local"; - - public string ClientId { get; set; } = "policy-engine"; - - public string? ClientSecret { get; set; } - - public IList Scopes { get; } = new List - { - StellaOpsScopes.PolicyRun, - StellaOpsScopes.FindingsRead, - StellaOpsScopes.EffectiveWrite - }; - - public int BackchannelTimeoutSeconds { get; set; } = 30; - - public void Validate() - { - if (!Enabled) - { - return; - } - - if (string.IsNullOrWhiteSpace(Issuer)) - { - throw new InvalidOperationException("Policy Engine authority configuration requires an issuer."); - } - - if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri) - { - throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI."); - } - - if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback) - { - throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback."); - } - - if (string.IsNullOrWhiteSpace(ClientId)) - { - throw new InvalidOperationException("Policy Engine authority configuration requires a clientId."); - } - - if (Scopes.Count == 0) - { - throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope."); - } - - if (BackchannelTimeoutSeconds <= 0) - { - throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero."); - } - } -} - -public sealed class PolicyEngineStorageOptions -{ - public string ConnectionString { get; set; } = "mongodb://localhost:27017/policy-engine"; - - public string DatabaseName { get; set; } = "policy_engine"; - - public int CommandTimeoutSeconds { get; set; } = 30; - - public void Validate() - { - if (string.IsNullOrWhiteSpace(ConnectionString)) - { - throw new InvalidOperationException("Policy Engine storage configuration requires a MongoDB connection string."); - } - - if (string.IsNullOrWhiteSpace(DatabaseName)) - { - throw new InvalidOperationException("Policy Engine storage configuration requires a database name."); - } - - if (CommandTimeoutSeconds <= 0) - { - throw new InvalidOperationException("Policy Engine storage command timeout must be greater than zero."); - } - } - - public TimeSpan CommandTimeout => TimeSpan.FromSeconds(CommandTimeoutSeconds); -} - -public sealed class PolicyEngineWorkerOptions -{ - public int SchedulerIntervalSeconds { get; set; } = 15; - - public int MaxConcurrentEvaluations { get; set; } = 4; - - public void Validate() - { - if (SchedulerIntervalSeconds <= 0) - { - throw new InvalidOperationException("Policy Engine worker interval must be greater than zero."); - } - - if (MaxConcurrentEvaluations <= 0) - { - throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero."); - } - } -} - -public sealed class PolicyEngineResourceServerOptions -{ - public string Authority { get; set; } = "https://authority.stella-ops.local"; - - public IList Audiences { get; } = new List { "api://policy-engine" }; - - public IList RequiredScopes { get; } = new List { StellaOpsScopes.PolicyRun }; - - public IList RequiredTenants { get; } = new List(); - - public IList BypassNetworks { get; } = new List { "127.0.0.1/32", "::1/128" }; - - public bool RequireHttpsMetadata { get; set; } = true; - - public void Validate() - { - if (string.IsNullOrWhiteSpace(Authority)) - { - throw new InvalidOperationException("Resource server configuration requires an Authority URL."); - } - - if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri)) - { - throw new InvalidOperationException("Resource server Authority URL must be absolute."); - } - - if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required."); - } - } -} +using System.Collections.ObjectModel; +using StellaOps.Auth.Abstractions; + +namespace StellaOps.Policy.Engine.Options; + +/// +/// Root configuration for the Policy Engine host. +/// +public sealed class PolicyEngineOptions +{ + public const string SectionName = "PolicyEngine"; + + public PolicyEngineAuthorityOptions Authority { get; } = new(); + + public PolicyEngineStorageOptions Storage { get; } = new(); + + public PolicyEngineWorkerOptions Workers { get; } = new(); + + public PolicyEngineResourceServerOptions ResourceServer { get; } = new(); + + public PolicyEngineCompilationOptions Compilation { get; } = new(); + + public PolicyEngineActivationOptions Activation { get; } = new(); + + public void Validate() + { + Authority.Validate(); + Storage.Validate(); + Workers.Validate(); + ResourceServer.Validate(); + Compilation.Validate(); + Activation.Validate(); + } +} + +public sealed class PolicyEngineAuthorityOptions +{ + public bool Enabled { get; set; } = true; + + public string Issuer { get; set; } = "https://authority.stella-ops.local"; + + public string ClientId { get; set; } = "policy-engine"; + + public string? ClientSecret { get; set; } + + public IList Scopes { get; } = new List + { + StellaOpsScopes.PolicyRun, + StellaOpsScopes.FindingsRead, + StellaOpsScopes.EffectiveWrite + }; + + public int BackchannelTimeoutSeconds { get; set; } = 30; + + public void Validate() + { + if (!Enabled) + { + return; + } + + if (string.IsNullOrWhiteSpace(Issuer)) + { + throw new InvalidOperationException("Policy Engine authority configuration requires an issuer."); + } + + if (!Uri.TryCreate(Issuer, UriKind.Absolute, out var issuerUri) || !issuerUri.IsAbsoluteUri) + { + throw new InvalidOperationException("Policy Engine authority issuer must be an absolute URI."); + } + + if (issuerUri.Scheme != Uri.UriSchemeHttps && !issuerUri.IsLoopback) + { + throw new InvalidOperationException("Policy Engine authority issuer must use HTTPS unless targeting loopback."); + } + + if (string.IsNullOrWhiteSpace(ClientId)) + { + throw new InvalidOperationException("Policy Engine authority configuration requires a clientId."); + } + + if (Scopes.Count == 0) + { + throw new InvalidOperationException("Policy Engine authority configuration requires at least one scope."); + } + + if (BackchannelTimeoutSeconds <= 0) + { + throw new InvalidOperationException("Policy Engine authority backchannel timeout must be greater than zero."); + } + } +} + +public sealed class PolicyEngineStorageOptions +{ + public string ConnectionString { get; set; } = "mongodb://localhost:27017/policy-engine"; + + public string DatabaseName { get; set; } = "policy_engine"; + + public int CommandTimeoutSeconds { get; set; } = 30; + + public void Validate() + { + if (string.IsNullOrWhiteSpace(ConnectionString)) + { + throw new InvalidOperationException("Policy Engine storage configuration requires a MongoDB connection string."); + } + + if (string.IsNullOrWhiteSpace(DatabaseName)) + { + throw new InvalidOperationException("Policy Engine storage configuration requires a database name."); + } + + if (CommandTimeoutSeconds <= 0) + { + throw new InvalidOperationException("Policy Engine storage command timeout must be greater than zero."); + } + } + + public TimeSpan CommandTimeout => TimeSpan.FromSeconds(CommandTimeoutSeconds); +} + +public sealed class PolicyEngineWorkerOptions +{ + public int SchedulerIntervalSeconds { get; set; } = 15; + + public int MaxConcurrentEvaluations { get; set; } = 4; + + public void Validate() + { + if (SchedulerIntervalSeconds <= 0) + { + throw new InvalidOperationException("Policy Engine worker interval must be greater than zero."); + } + + if (MaxConcurrentEvaluations <= 0) + { + throw new InvalidOperationException("Policy Engine worker concurrency must be greater than zero."); + } + } +} + +public sealed class PolicyEngineResourceServerOptions +{ + public string Authority { get; set; } = "https://authority.stella-ops.local"; + + public IList Audiences { get; } = new List { "api://policy-engine" }; + + public IList RequiredScopes { get; } = new List { StellaOpsScopes.PolicyRun }; + + public IList RequiredTenants { get; } = new List(); + + public IList BypassNetworks { get; } = new List { "127.0.0.1/32", "::1/128" }; + + public bool RequireHttpsMetadata { get; set; } = true; + + public void Validate() + { + if (string.IsNullOrWhiteSpace(Authority)) + { + throw new InvalidOperationException("Resource server configuration requires an Authority URL."); + } + + if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException("Resource server Authority URL must be absolute."); + } + + if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required."); + } + } +} + +public sealed class PolicyEngineCompilationOptions +{ + /// + /// Maximum allowed complexity score for compiled policies. Set to <= 0 to disable. + /// + public double MaxComplexityScore { get; set; } = 750d; + + /// + /// Maximum allowed compilation wall-clock duration in milliseconds. Set to <= 0 to disable. + /// + public int MaxDurationMilliseconds { get; set; } = 1500; + + public bool EnforceComplexity => MaxComplexityScore > 0; + + public bool EnforceDuration => MaxDurationMilliseconds > 0; + + public void Validate() + { + if (MaxComplexityScore < 0) + { + throw new InvalidOperationException("Compilation.maxComplexityScore must be greater than or equal to zero."); + } + + if (MaxDurationMilliseconds < 0) + { + throw new InvalidOperationException("Compilation.maxDurationMilliseconds must be greater than or equal to zero."); + } + } +} + + +public sealed class PolicyEngineActivationOptions +{ + /// + /// Forces two distinct approvals for every activation regardless of the request payload. + /// + public bool ForceTwoPersonApproval { get; set; } = false; + + /// + /// Default value applied when callers omit requiresTwoPersonApproval. + /// + public bool DefaultRequiresTwoPersonApproval { get; set; } = false; + + /// + /// Emits structured audit logs for every activation attempt. + /// + public bool EmitAuditLogs { get; set; } = true; + + public void Validate() + { + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Program.cs b/src/Policy/StellaOps.Policy.Engine/Program.cs index 52d660fe5..99e7abba0 100644 --- a/src/Policy/StellaOps.Policy.Engine/Program.cs +++ b/src/Policy/StellaOps.Policy.Engine/Program.cs @@ -13,7 +13,24 @@ using StellaOps.Policy.Engine.Services; using StellaOps.Policy.Engine.Workers; using StellaOps.AirGap.Policy; -var builder = WebApplication.CreateBuilder(args); +var builder = WebApplication.CreateBuilder(args); + +var policyEngineConfigFiles = new[] +{ + "../etc/policy-engine.yaml", + "../etc/policy-engine.local.yaml", + "policy-engine.yaml", + "policy-engine.local.yaml" +}; + +var policyEngineActivationConfigFiles = new[] +{ + "../etc/policy-engine.activation.yaml", + "../etc/policy-engine.activation.local.yaml", + "/config/policy-engine/activation.yaml", + "policy-engine.activation.yaml", + "policy-engine.activation.local.yaml" +}; builder.Logging.ClearProviders(); builder.Logging.AddConsole(); @@ -25,41 +42,41 @@ builder.Configuration.AddStellaOpsDefaults(options => options.ConfigureBuilder = configurationBuilder => { var contentRoot = builder.Environment.ContentRootPath; - foreach (var relative in new[] - { - "../etc/policy-engine.yaml", - "../etc/policy-engine.local.yaml", - "policy-engine.yaml", - "policy-engine.local.yaml" - }) - { - var path = Path.Combine(contentRoot, relative); - configurationBuilder.AddYamlFile(path, optional: true); - } - }; -}); - -var bootstrap = StellaOpsConfigurationBootstrapper.Build(options => + foreach (var relative in policyEngineConfigFiles) + { + var path = Path.Combine(contentRoot, relative); + configurationBuilder.AddYamlFile(path, optional: true); + } + + foreach (var relative in policyEngineActivationConfigFiles) + { + var path = Path.Combine(contentRoot, relative); + configurationBuilder.AddYamlFile(path, optional: true); + } + }; +}); + +var bootstrap = StellaOpsConfigurationBootstrapper.Build(options => { options.BasePath = builder.Environment.ContentRootPath; options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_"; options.BindingSection = PolicyEngineOptions.SectionName; options.ConfigureBuilder = configurationBuilder => { - foreach (var relative in new[] - { - "../etc/policy-engine.yaml", - "../etc/policy-engine.local.yaml", - "policy-engine.yaml", - "policy-engine.local.yaml" - }) - { - var path = Path.Combine(builder.Environment.ContentRootPath, relative); - configurationBuilder.AddYamlFile(path, optional: true); - } - }; - options.PostBind = static (value, _) => value.Validate(); -}); + foreach (var relative in policyEngineConfigFiles) + { + var path = Path.Combine(builder.Environment.ContentRootPath, relative); + configurationBuilder.AddYamlFile(path, optional: true); + } + + foreach (var relative in policyEngineActivationConfigFiles) + { + var path = Path.Combine(builder.Environment.ContentRootPath, relative); + configurationBuilder.AddYamlFile(path, optional: true); + } + }; + options.PostBind = static (value, _) => value.Validate(); +}); builder.Configuration.AddConfiguration(bootstrap.Configuration); diff --git a/src/Policy/StellaOps.Policy.Engine/Properties/AssemblyInfo.cs b/src/Policy/StellaOps.Policy.Engine/Properties/AssemblyInfo.cs index b845e3ac1..826700d82 100644 --- a/src/Policy/StellaOps.Policy.Engine/Properties/AssemblyInfo.cs +++ b/src/Policy/StellaOps.Policy.Engine/Properties/AssemblyInfo.cs @@ -1,3 +1,3 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")] +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Policy.Engine.Tests")] diff --git a/src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationAuditor.cs b/src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationAuditor.cs new file mode 100644 index 000000000..557c574a0 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationAuditor.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using StellaOps.Policy.Engine.Domain; +using StellaOps.Policy.Engine.Options; + +namespace StellaOps.Policy.Engine.Services; + +internal interface IPolicyActivationAuditor +{ + void RecordActivation( + string packId, + int version, + string actorId, + string? tenantId, + PolicyActivationResult result, + string? comment); +} + +internal sealed class PolicyActivationAuditor : IPolicyActivationAuditor +{ + private const int CommentLimit = 512; + + private readonly PolicyEngineOptions options; + private readonly ILogger logger; + + public PolicyActivationAuditor( + PolicyEngineOptions options, + ILogger logger) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void RecordActivation( + string packId, + int version, + string actorId, + string? tenantId, + PolicyActivationResult result, + string? comment) + { + if (!options.Activation.EmitAuditLogs) + { + return; + } + + ArgumentNullException.ThrowIfNull(packId); + ArgumentNullException.ThrowIfNull(actorId); + ArgumentNullException.ThrowIfNull(result); + + var normalizedStatus = NormalizeStatus(result.Status); + var scope = new Dictionary + { + ["policy.pack_id"] = packId, + ["policy.revision"] = version, + ["policy.activation.status"] = normalizedStatus, + ["policy.activation.actor"] = actorId + }; + + if (!string.IsNullOrWhiteSpace(tenantId)) + { + scope["policy.tenant"] = tenantId; + } + + if (!string.IsNullOrWhiteSpace(comment)) + { + scope["policy.activation.comment"] = Truncate(comment!, CommentLimit); + } + + if (result.Revision is { } revision) + { + scope["policy.activation.requires_two_person"] = revision.RequiresTwoPersonApproval; + scope["policy.activation.approval_count"] = revision.Approvals.Length; + if (revision.Approvals.Length > 0) + { + scope["policy.activation.approvers"] = revision.Approvals + .Select(static approval => approval.ActorId) + .Where(static actor => !string.IsNullOrWhiteSpace(actor)) + .ToArray(); + } + } + + using (logger.BeginScope(scope)) + { + logger.LogInformation( + "Policy activation {PackId}/{Revision} completed with status {Status}.", + packId, + version, + normalizedStatus); + } + } + + private static string NormalizeStatus(PolicyActivationResultStatus status) + => status.ToString().ToLowerInvariant(); + + private static string Truncate(string value, int maxLength) + => value.Length <= maxLength ? value : value[..maxLength]; +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationSettings.cs b/src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationSettings.cs new file mode 100644 index 000000000..2d1912521 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/PolicyActivationSettings.cs @@ -0,0 +1,34 @@ +using System; +using StellaOps.Policy.Engine.Options; + +namespace StellaOps.Policy.Engine.Services; + +internal interface IPolicyActivationSettings +{ + bool ResolveRequirement(bool? requested); +} + +internal sealed class PolicyActivationSettings : IPolicyActivationSettings +{ + private readonly PolicyEngineOptions options; + + public PolicyActivationSettings(PolicyEngineOptions options) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public bool ResolveRequirement(bool? requested) + { + if (options.Activation.ForceTwoPersonApproval) + { + return true; + } + + if (requested.HasValue) + { + return requested.Value; + } + + return options.Activation.DefaultRequiresTwoPersonApproval; + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/PolicyCompilationService.cs b/src/Policy/StellaOps.Policy.Engine/Services/PolicyCompilationService.cs index 23e74a0ee..92c3014e5 100644 --- a/src/Policy/StellaOps.Policy.Engine/Services/PolicyCompilationService.cs +++ b/src/Policy/StellaOps.Policy.Engine/Services/PolicyCompilationService.cs @@ -1,6 +1,9 @@ -using System.Collections.Immutable; -using StellaOps.Policy; -using StellaOps.Policy.Engine.Compilation; +using System; +using System.Collections.Immutable; +using Microsoft.Extensions.Options; +using StellaOps.Policy; +using StellaOps.Policy.Engine.Compilation; +using StellaOps.Policy.Engine.Options; namespace StellaOps.Policy.Engine.Services; @@ -8,14 +11,24 @@ namespace StellaOps.Policy.Engine.Services; /// Provides deterministic compilation for stella-dsl@1 policy documents and exposes /// basic statistics consumed by API/CLI surfaces. /// -internal sealed class PolicyCompilationService -{ - private readonly PolicyCompiler compiler; - - public PolicyCompilationService(PolicyCompiler compiler) - { - this.compiler = compiler ?? throw new ArgumentNullException(nameof(compiler)); - } +internal sealed class PolicyCompilationService +{ + private readonly PolicyCompiler compiler; + private readonly PolicyComplexityAnalyzer complexityAnalyzer; + private readonly IOptionsMonitor optionsMonitor; + private readonly TimeProvider timeProvider; + + public PolicyCompilationService( + PolicyCompiler compiler, + PolicyComplexityAnalyzer complexityAnalyzer, + IOptionsMonitor optionsMonitor, + TimeProvider timeProvider) + { + this.compiler = compiler ?? throw new ArgumentNullException(nameof(compiler)); + this.complexityAnalyzer = complexityAnalyzer ?? throw new ArgumentNullException(nameof(complexityAnalyzer)); + this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + this.timeProvider = timeProvider ?? TimeProvider.System; + } public PolicyCompilationResultDto Compile(PolicyCompileRequest request) { @@ -31,51 +44,96 @@ internal sealed class PolicyCompilationService if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal)) { - return PolicyCompilationResultDto.FromFailure( - ImmutableArray.Create(PolicyIssue.Error( - PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion, - $"Unsupported syntax '{request.Dsl.Syntax ?? "null"}'. Expected 'stella-dsl@1'.", - "dsl.syntax"))); - } + return PolicyCompilationResultDto.FromFailure( + ImmutableArray.Create(PolicyIssue.Error( + PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion, + $"Unsupported syntax '{request.Dsl.Syntax ?? "null"}'. Expected 'stella-dsl@1'.", + "dsl.syntax")), + complexity: null, + durationMilliseconds: 0); + } - var result = compiler.Compile(request.Dsl.Source); - if (!result.Success || result.Document is null) - { - return PolicyCompilationResultDto.FromFailure(result.Diagnostics); - } - - return PolicyCompilationResultDto.FromSuccess(result); - } -} + var start = timeProvider.GetTimestamp(); + var result = compiler.Compile(request.Dsl.Source); + var elapsed = timeProvider.GetElapsedTime(start, timeProvider.GetTimestamp()); + var durationMilliseconds = (long)Math.Ceiling(elapsed.TotalMilliseconds); + + if (!result.Success || result.Document is null) + { + return PolicyCompilationResultDto.FromFailure(result.Diagnostics, null, durationMilliseconds); + } + + var complexity = complexityAnalyzer.Analyze(result.Document); + var diagnostics = result.Diagnostics.IsDefault ? ImmutableArray.Empty : result.Diagnostics; + var limits = optionsMonitor.CurrentValue?.Compilation ?? new PolicyEngineCompilationOptions(); + + if (limits.EnforceComplexity && complexity.Score > limits.MaxComplexityScore) + { + var diagnostic = PolicyIssue.Error( + PolicyEngineDiagnosticCodes.CompilationComplexityExceeded, + $"Policy complexity score {complexity.Score:F2} exceeds configured maximum {limits.MaxComplexityScore:F2}. Reduce rule count or expression depth.", + "$.rules"); + diagnostics = AppendDiagnostic(diagnostics, diagnostic); + return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds); + } + + if (limits.EnforceDuration && durationMilliseconds > limits.MaxDurationMilliseconds) + { + var diagnostic = PolicyIssue.Error( + PolicyEngineDiagnosticCodes.CompilationComplexityExceeded, + $"Policy compilation time {durationMilliseconds} ms exceeded limit {limits.MaxDurationMilliseconds} ms.", + "$.dsl"); + diagnostics = AppendDiagnostic(diagnostics, diagnostic); + return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds); + } + + return PolicyCompilationResultDto.FromSuccess(result, complexity, durationMilliseconds); + } + + private static ImmutableArray AppendDiagnostic(ImmutableArray diagnostics, PolicyIssue diagnostic) + => diagnostics.IsDefault + ? ImmutableArray.Create(diagnostic) + : diagnostics.Add(diagnostic); +} internal sealed record PolicyCompileRequest(PolicyDslPayload Dsl); internal sealed record PolicyDslPayload(string Syntax, string Source); -internal sealed record PolicyCompilationResultDto( - bool Success, - string? Digest, - PolicyCompilationStatistics? Statistics, - ImmutableArray Diagnostics) -{ - public static PolicyCompilationResultDto FromFailure(ImmutableArray diagnostics) => - new(false, null, null, diagnostics); - - public static PolicyCompilationResultDto FromSuccess(PolicyCompilationResult compilationResult) - { - if (compilationResult.Document is null) - { - throw new ArgumentException("Compilation result must include a document for success.", nameof(compilationResult)); - } - - var stats = PolicyCompilationStatistics.Create(compilationResult.Document); - return new PolicyCompilationResultDto( - true, - $"sha256:{compilationResult.Checksum}", - stats, - compilationResult.Diagnostics); - } -} +internal sealed record PolicyCompilationResultDto( + bool Success, + string? Digest, + PolicyCompilationStatistics? Statistics, + ImmutableArray Diagnostics, + PolicyComplexityReport? Complexity, + long DurationMilliseconds) +{ + public static PolicyCompilationResultDto FromFailure( + ImmutableArray diagnostics, + PolicyComplexityReport? complexity, + long durationMilliseconds) => + new(false, null, null, diagnostics, complexity, durationMilliseconds); + + public static PolicyCompilationResultDto FromSuccess( + PolicyCompilationResult compilationResult, + PolicyComplexityReport complexity, + long durationMilliseconds) + { + if (compilationResult.Document is null) + { + throw new ArgumentException("Compilation result must include a document for success.", nameof(compilationResult)); + } + + var stats = PolicyCompilationStatistics.Create(compilationResult.Document); + return new PolicyCompilationResultDto( + true, + $"sha256:{compilationResult.Checksum}", + stats, + compilationResult.Diagnostics, + complexity, + durationMilliseconds); + } +} internal sealed record PolicyCompilationStatistics( int RuleCount, diff --git a/src/Policy/StellaOps.Policy.Engine/Services/PolicyEngineDiagnosticCodes.cs b/src/Policy/StellaOps.Policy.Engine/Services/PolicyEngineDiagnosticCodes.cs new file mode 100644 index 000000000..97b6209f8 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/PolicyEngineDiagnosticCodes.cs @@ -0,0 +1,6 @@ +namespace StellaOps.Policy.Engine.Services; + +internal static class PolicyEngineDiagnosticCodes +{ + public const string CompilationComplexityExceeded = "ERR_POL_COMPLEXITY"; +} diff --git a/src/Policy/StellaOps.Policy.Engine/TASKS.md b/src/Policy/StellaOps.Policy.Engine/TASKS.md index 793e8de7c..d2aa26cbe 100644 --- a/src/Policy/StellaOps.Policy.Engine/TASKS.md +++ b/src/Policy/StellaOps.Policy.Engine/TASKS.md @@ -45,8 +45,8 @@ |----|--------|----------|------------|-------------|---------------| | POLICY-ENGINE-27-001 | TODO | Policy Guild | POLICY-ENGINE-20-001, REGISTRY-API-27-003 | Extend compile outputs to include rule coverage metadata, symbol table, inline documentation, and rule index for editor autocomplete; persist deterministic hashes. | Compile endpoint returns coverage + symbol table; responses validated with fixtures; hashing deterministic across runs; docs updated. | | POLICY-ENGINE-27-002 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-20-002, POLICY-ENGINE-27-001 | Enhance simulate endpoints to emit rule firing counts, heatmap aggregates, sampled explain traces with deterministic ordering, and delta summaries for quick/batch sims. | Simulation outputs include ordered heatmap + sample explains; integration tests verify determinism; telemetry emits `policy_rule_fired_total`. | -| POLICY-ENGINE-27-003 | TODO | Policy Guild, Security Guild | POLICY-ENGINE-20-005 | Implement complexity/time limit enforcement with compiler scoring, configurable thresholds, and structured diagnostics (`ERR_POL_COMPLEXITY`). | Policies exceeding limits return actionable diagnostics; limits configurable per tenant; regression tests cover allow/block cases. | -| POLICY-ENGINE-27-004 | TODO | Policy Guild, QA Guild | POLICY-ENGINE-27-001..003 | Update golden/property tests to cover new coverage metrics, symbol tables, explain traces, and complexity limits; provide fixtures for Registry/Console integration. | Test suites extended; fixtures shared under `StellaOps.Policy.Engine.Tests/Fixtures/policy-studio`; CI ensures determinism across runs. | +| POLICY-ENGINE-27-003 | DONE | Policy Guild, Security Guild | POLICY-ENGINE-20-005 | Implement complexity/time limit enforcement with compiler scoring, configurable thresholds, and structured diagnostics (`ERR_POL_COMPLEXITY`). | Policies exceeding limits return actionable diagnostics; limits configurable per tenant; regression tests cover allow/block cases. | +| POLICY-ENGINE-27-004 | DONE | Policy Guild, QA Guild | POLICY-ENGINE-27-001..003 | Update golden/property tests to cover new coverage metrics, symbol tables, explain traces, and complexity limits; provide fixtures for Registry/Console integration. | Test suites extended; fixtures shared under `StellaOps.Policy.Engine.Tests/Fixtures/policy-studio`; CI ensures determinism across runs. | ## Epic 3: Graph Explorer v1 diff --git a/src/Policy/StellaOps.Policy.Gateway/Contracts/PolicyPackContracts.cs b/src/Policy/StellaOps.Policy.Gateway/Contracts/PolicyPackContracts.cs index de9fefc45..08d02f8d8 100644 --- a/src/Policy/StellaOps.Policy.Gateway/Contracts/PolicyPackContracts.cs +++ b/src/Policy/StellaOps.Policy.Gateway/Contracts/PolicyPackContracts.cs @@ -1,45 +1,45 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; - -namespace StellaOps.Policy.Gateway.Contracts; - -public sealed record PolicyPackSummaryDto( - string PackId, - string? DisplayName, - DateTimeOffset CreatedAt, - IReadOnlyList Versions); - -public sealed record PolicyPackDto( - string PackId, - string? DisplayName, - DateTimeOffset CreatedAt, - IReadOnlyList Revisions); - -public sealed record PolicyRevisionDto( - int Version, - string Status, - bool RequiresTwoPersonApproval, - DateTimeOffset CreatedAt, - DateTimeOffset? ActivatedAt, - IReadOnlyList Approvals); - -public sealed record PolicyActivationApprovalDto( - string ActorId, - DateTimeOffset ApprovedAt, - string? Comment); - -public sealed record PolicyRevisionActivationDto( - string Status, - PolicyRevisionDto Revision); - -public sealed record CreatePolicyPackRequest( - [StringLength(200)] string? PackId, - [StringLength(200)] string? DisplayName); - -public sealed record CreatePolicyRevisionRequest( - int? Version, - bool RequiresTwoPersonApproval, - string InitialStatus = "Approved"); - -public sealed record ActivatePolicyRevisionRequest(string? Comment); +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace StellaOps.Policy.Gateway.Contracts; + +public sealed record PolicyPackSummaryDto( + string PackId, + string? DisplayName, + DateTimeOffset CreatedAt, + IReadOnlyList Versions); + +public sealed record PolicyPackDto( + string PackId, + string? DisplayName, + DateTimeOffset CreatedAt, + IReadOnlyList Revisions); + +public sealed record PolicyRevisionDto( + int Version, + string Status, + bool RequiresTwoPersonApproval, + DateTimeOffset CreatedAt, + DateTimeOffset? ActivatedAt, + IReadOnlyList Approvals); + +public sealed record PolicyActivationApprovalDto( + string ActorId, + DateTimeOffset ApprovedAt, + string? Comment); + +public sealed record PolicyRevisionActivationDto( + string Status, + PolicyRevisionDto Revision); + +public sealed record CreatePolicyPackRequest( + [StringLength(200)] string? PackId, + [StringLength(200)] string? DisplayName); + +public sealed record CreatePolicyRevisionRequest( + int? Version, + bool? RequiresTwoPersonApproval, + string InitialStatus = "Approved"); + +public sealed record ActivatePolicyRevisionRequest(string? Comment); diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationAuditorTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationAuditorTests.cs new file mode 100644 index 000000000..4f1413513 --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationAuditorTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using StellaOps.Policy.Engine.Domain; +using StellaOps.Policy.Engine.Options; +using StellaOps.Policy.Engine.Services; +using Xunit; + +namespace StellaOps.Policy.Engine.Tests; + +public class PolicyActivationAuditorTests +{ + [Fact] + public void RecordActivation_WhenDisabled_DoesNothing() + { + var options = new PolicyEngineOptions(); + options.Activation.EmitAuditLogs = false; + var logger = new TestLogger(); + var auditor = new PolicyActivationAuditor(options, logger); + var result = new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null); + + auditor.RecordActivation("pack", 1, "alice", null, result, null); + + Assert.Empty(logger.Entries); + } + + [Fact] + public void RecordActivation_WhenEnabled_WritesScopedLog() + { + var options = new PolicyEngineOptions(); + options.Activation.EmitAuditLogs = true; + var logger = new TestLogger(); + var auditor = new PolicyActivationAuditor(options, logger); + + var revision = new PolicyRevisionRecord(1, true, PolicyRevisionStatus.Approved, DateTimeOffset.UtcNow); + revision.AddApproval(new PolicyActivationApproval("alice", DateTimeOffset.UtcNow, "first")); + var result = new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, revision); + + auditor.RecordActivation("pack-a", 3, "bob", "tenant-x", result, "please rollout"); + + var entry = Assert.Single(logger.Entries); + Assert.Contains("pack-a", entry.Message); + Assert.Contains("pendingsecondapproval", entry.Message.ToLowerInvariant()); + + var scope = Assert.IsType>(Assert.Single(entry.Scopes)); + Assert.Equal("tenant-x", scope["policy.tenant"]); + Assert.Equal("bob", scope["policy.activation.actor"]); + Assert.True((bool)scope["policy.activation.requires_two_person"]!); + Assert.Equal(1, scope["policy.activation.approval_count"]); + } + + private sealed record LogEntry(LogLevel Level, string Message, IReadOnlyList Scopes); + + private sealed class TestLogger : ILogger + { + private readonly Stack scopeStack = new(); + + public List Entries { get; } = new(); + + IDisposable ILogger.BeginScope(TState state) + { + scopeStack.Push(state); + return new DisposeAction(scopeStack); + } + + bool ILogger.IsEnabled(LogLevel logLevel) => true; + + void ILogger.Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception), scopeStack.Count > 0 ? new List(scopeStack) : new List())); + } + + private sealed class DisposeAction : IDisposable + { + private readonly Stack stack; + + public DisposeAction(Stack stack) + { + this.stack = stack; + } + + public void Dispose() + { + if (stack.Count > 0) + { + stack.Pop(); + } + } + } + } +} diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationSettingsTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationSettingsTests.cs new file mode 100644 index 000000000..f63884dee --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyActivationSettingsTests.cs @@ -0,0 +1,42 @@ +using StellaOps.Policy.Engine.Options; +using StellaOps.Policy.Engine.Services; +using Xunit; + +namespace StellaOps.Policy.Engine.Tests; + +public class PolicyActivationSettingsTests +{ + [Fact] + public void ResolveRequirement_WhenForceEnabled_IgnoresRequest() + { + var options = new PolicyEngineOptions(); + options.Activation.ForceTwoPersonApproval = true; + var settings = new PolicyActivationSettings(options); + + Assert.True(settings.ResolveRequirement(false)); + Assert.True(settings.ResolveRequirement(null)); + } + + [Fact] + public void ResolveRequirement_UsesRequestedValue_WhenProvided() + { + var options = new PolicyEngineOptions(); + var settings = new PolicyActivationSettings(options); + + Assert.True(settings.ResolveRequirement(true)); + Assert.False(settings.ResolveRequirement(false)); + } + + [Fact] + public void ResolveRequirement_FallsBackToDefault_WhenRequestMissing() + { + var options = new PolicyEngineOptions(); + options.Activation.DefaultRequiresTwoPersonApproval = true; + var settings = new PolicyActivationSettings(options); + + Assert.True(settings.ResolveRequirement(null)); + + options.Activation.DefaultRequiresTwoPersonApproval = false; + Assert.False(settings.ResolveRequirement(null)); + } +} diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyCompilationServiceTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyCompilationServiceTests.cs new file mode 100644 index 000000000..401918408 --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyCompilationServiceTests.cs @@ -0,0 +1,136 @@ +using System; +using Microsoft.Extensions.Options; +using StellaOps.Policy; +using StellaOps.Policy.Engine.Compilation; +using StellaOps.Policy.Engine.Options; +using StellaOps.Policy.Engine.Services; +using Xunit; + +namespace StellaOps.Policy.Engine.Tests; + +public sealed class PolicyCompilationServiceTests +{ + private const string SimplePolicy = """ + policy "Sample" syntax "stella-dsl@1" { + rule block_high priority 10 { + when severity.normalized >= "High" + then status := "blocked" + because "Block high severity findings" + } + + rule warn_medium priority 20 { + when severity.normalized >= "Medium" + then status := "warn" + because "Warn on medium severity findings" + } + } + """; + + [Fact] + public void Compile_ReturnsComplexityReport_WhenWithinLimits() + { + var service = CreateService(maxComplexityScore: 1000, maxDurationMilliseconds: 1000, simulatedDurationMilliseconds: 12.3); + var request = new PolicyCompileRequest(new PolicyDslPayload("stella-dsl@1", SimplePolicy)); + + var result = service.Compile(request); + + Assert.True(result.Success); + Assert.NotNull(result.Digest); + Assert.NotNull(result.Complexity); + Assert.True(result.Complexity!.Score > 0); + Assert.True(result.Complexity.RuleCount >= 2); + Assert.Equal(13, result.DurationMilliseconds); + Assert.True(result.Diagnostics.IsDefaultOrEmpty); + } + + [Fact] + public void Compile_Fails_WhenComplexityExceedsThreshold() + { + var service = CreateService(maxComplexityScore: 1, maxDurationMilliseconds: 1000, simulatedDurationMilliseconds: 2); + var request = new PolicyCompileRequest(new PolicyDslPayload("stella-dsl@1", SimplePolicy)); + + var result = service.Compile(request); + + Assert.False(result.Success); + Assert.NotNull(result.Complexity); + Assert.Equal(2, result.DurationMilliseconds); + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal(PolicyEngineDiagnosticCodes.CompilationComplexityExceeded, diagnostic.Code); + Assert.Equal(PolicyIssueSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Compile_Fails_WhenDurationExceedsThreshold() + { + var service = CreateService(maxComplexityScore: 1000, maxDurationMilliseconds: 1, simulatedDurationMilliseconds: 5.2); + var request = new PolicyCompileRequest(new PolicyDslPayload("stella-dsl@1", SimplePolicy)); + + var result = service.Compile(request); + + Assert.False(result.Success); + Assert.NotNull(result.Complexity); + Assert.Equal(6, result.DurationMilliseconds); + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal(PolicyEngineDiagnosticCodes.CompilationComplexityExceeded, diagnostic.Code); + } + + private static PolicyCompilationService CreateService(double maxComplexityScore, int maxDurationMilliseconds, double simulatedDurationMilliseconds) + { + var compiler = new PolicyCompiler(); + var analyzer = new PolicyComplexityAnalyzer(); + var options = new PolicyEngineOptions(); + options.Compilation.MaxComplexityScore = maxComplexityScore; + options.Compilation.MaxDurationMilliseconds = maxDurationMilliseconds; + var optionsMonitor = new StaticOptionsMonitor(options); + var timeProvider = new FakeTimeProvider(simulatedDurationMilliseconds); + return new PolicyCompilationService(compiler, analyzer, optionsMonitor, timeProvider); + } + + private sealed class StaticOptionsMonitor : IOptionsMonitor + where T : class + { + public StaticOptionsMonitor(T value) + { + CurrentValue = value ?? throw new ArgumentNullException(nameof(value)); + } + + public T CurrentValue { get; } + + public T Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) => Disposable.Instance; + + private sealed class Disposable : IDisposable + { + public static readonly Disposable Instance = new(); + public void Dispose() + { + } + } + } + + private sealed class FakeTimeProvider : TimeProvider + { + private readonly long elapsedCounts; + private readonly long frequency = 1_000_000; + private bool firstCall = true; + + public FakeTimeProvider(double milliseconds) + { + elapsedCounts = (long)Math.Round(milliseconds * frequency / 1000d); + } + + public override long GetTimestamp() + { + if (firstCall) + { + firstCall = false; + return 0; + } + + return elapsedCounts; + } + + public override long TimestampFrequency => frequency; + } +} diff --git a/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GatewayActivationTests.cs b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GatewayActivationTests.cs index 6041654ed..e5afa0126 100644 --- a/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GatewayActivationTests.cs +++ b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GatewayActivationTests.cs @@ -1,548 +1,637 @@ -using System.Diagnostics.Metrics; -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; -using Polly.Utilities; -using StellaOps.Auth.Client; -using StellaOps.Auth.Abstractions; -using StellaOps.Policy.Gateway.Clients; -using StellaOps.Policy.Gateway.Contracts; -using StellaOps.Policy.Gateway.Options; -using StellaOps.Policy.Gateway.Services; -using Xunit; -using Xunit.Sdk; - -namespace StellaOps.Policy.Gateway.Tests; - -public sealed class GatewayActivationTests -{ - [Fact] - public async Task ActivateRevision_UsesServiceTokenFallback_And_RecordsMetrics() - { - await using var factory = new PolicyGatewayWebApplicationFactory(); - - var tokenClient = factory.Services.GetRequiredService(); - tokenClient.Reset(); - - var recordingHandler = factory.Services.GetRequiredService(); - recordingHandler.Reset(); - - using var listener = new MeterListener(); - var activationMeasurements = new List<(long Value, string Outcome, string Source)>(); - var latencyMeasurements = new List<(double Value, string Outcome, string Source)>(); - - listener.InstrumentPublished += (instrument, meterListener) => - { - if (instrument.Meter.Name != "StellaOps.Policy.Gateway") - { - return; - } - - meterListener.EnableMeasurementEvents(instrument); - }; - - listener.SetMeasurementEventCallback((instrument, value, tags, _) => - { - if (instrument.Name != "policy_gateway_activation_requests_total") - { - return; - } - - activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); - }); - - listener.SetMeasurementEventCallback((instrument, value, tags, _) => - { - if (instrument.Name != "policy_gateway_activation_latency_ms") - { - return; - } - - latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); - }); - - listener.Start(); - - using var client = factory.CreateClient(); - - var response = await client.PostAsJsonAsync( - "/api/policy/packs/example/revisions/5:activate", - new ActivatePolicyRevisionRequest("rollout window start")); - - listener.Dispose(); - - var forwardedRequest = recordingHandler.LastRequest; - var issuedTokens = tokenClient.RequestCount; - var responseBody = await response.Content.ReadAsStringAsync(); - if (!response.IsSuccessStatusCode) - { - throw new Xunit.Sdk.XunitException( - $"Gateway response was {(int)response.StatusCode} {response.StatusCode}. " + - $"Body: {responseBody}. IssuedTokens: {issuedTokens}. Forwarded: { (forwardedRequest is null ? "no" : "yes") }."); - } - - Assert.Equal(1, tokenClient.RequestCount); - - Assert.NotNull(forwardedRequest); - Assert.Equal(HttpMethod.Post, forwardedRequest!.Method); - Assert.Equal("https://policy-engine.test/api/policy/packs/example/revisions/5:activate", forwardedRequest.RequestUri!.ToString()); - Assert.Equal("Bearer", forwardedRequest.Headers.Authorization?.Scheme); - Assert.Equal("service-token", forwardedRequest.Headers.Authorization?.Parameter); - Assert.False(forwardedRequest.Headers.TryGetValues("DPoP", out _), "Expected no DPoP header when DPoP is disabled."); - - Assert.Contains(activationMeasurements, measurement => - measurement.Value == 1 && - measurement.Outcome == "activated" && - measurement.Source == "service"); - - Assert.Contains(latencyMeasurements, measurement => - measurement.Outcome == "activated" && - measurement.Source == "service"); - } - - [Fact] - public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsUnauthorized() - { - await using var factory = new PolicyGatewayWebApplicationFactory(); - - var tokenClient = factory.Services.GetRequiredService(); - tokenClient.Reset(); - - var recordingHandler = factory.Services.GetRequiredService(); - recordingHandler.Reset(); - recordingHandler.SetResponseFactory(_ => - { - var problem = new ProblemDetails - { - Title = "Unauthorized", - Detail = "Caller token rejected.", - Status = StatusCodes.Status401Unauthorized - }; - return new HttpResponseMessage(HttpStatusCode.Unauthorized) - { - Content = JsonContent.Create(problem) - }; - }); - - using var listener = new MeterListener(); - var activationMeasurements = new List<(long Value, string Outcome, string Source)>(); - var latencyMeasurements = new List<(double Value, string Outcome, string Source)>(); - - listener.InstrumentPublished += (instrument, meterListener) => - { - if (instrument.Meter.Name != "StellaOps.Policy.Gateway") - { - return; - } - - meterListener.EnableMeasurementEvents(instrument); - }; - - listener.SetMeasurementEventCallback((instrument, value, tags, _) => - { - if (instrument.Name != "policy_gateway_activation_requests_total") - { - return; - } - - activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); - }); - - listener.SetMeasurementEventCallback((instrument, value, tags, _) => - { - if (instrument.Name != "policy_gateway_activation_latency_ms") - { - return; - } - - latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); - }); - - listener.Start(); - - using var client = factory.CreateClient(); - - var response = await client.PostAsJsonAsync( - "/api/policy/packs/example/revisions/2:activate", - new ActivatePolicyRevisionRequest("failure path")); - - listener.Dispose(); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - Assert.Equal(1, tokenClient.RequestCount); - - var forwardedRequest = recordingHandler.LastRequest; - Assert.NotNull(forwardedRequest); - Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter); - - Assert.Contains(activationMeasurements, measurement => - measurement.Value == 1 && - measurement.Outcome == "unauthorized" && - measurement.Source == "service"); - - Assert.Contains(latencyMeasurements, measurement => - measurement.Outcome == "unauthorized" && - measurement.Source == "service"); - } - - [Fact] - public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsBadGateway() - { - await using var factory = new PolicyGatewayWebApplicationFactory(); - - var tokenClient = factory.Services.GetRequiredService(); - tokenClient.Reset(); - - var recordingHandler = factory.Services.GetRequiredService(); - recordingHandler.Reset(); - recordingHandler.SetResponseFactory(_ => - { - var problem = new ProblemDetails - { - Title = "Upstream error", - Detail = "Policy Engine returned 502.", - Status = StatusCodes.Status502BadGateway - }; - return new HttpResponseMessage(HttpStatusCode.BadGateway) - { - Content = JsonContent.Create(problem) - }; - }); - - using var listener = new MeterListener(); - var activationMeasurements = new List<(long Value, string Outcome, string Source)>(); - var latencyMeasurements = new List<(double Value, string Outcome, string Source)>(); - - listener.InstrumentPublished += (instrument, meterListener) => - { - if (instrument.Meter.Name != "StellaOps.Policy.Gateway") - { - return; - } - - meterListener.EnableMeasurementEvents(instrument); - }; - - listener.SetMeasurementEventCallback((instrument, value, tags, _) => - { - if (instrument.Name != "policy_gateway_activation_requests_total") - { - return; - } - - activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); - }); - - listener.SetMeasurementEventCallback((instrument, value, tags, _) => - { - if (instrument.Name != "policy_gateway_activation_latency_ms") - { - return; - } - - latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); - }); - - listener.Start(); - - using var client = factory.CreateClient(); - - var response = await client.PostAsJsonAsync( - "/api/policy/packs/example/revisions/3:activate", - new ActivatePolicyRevisionRequest("upstream failure")); - - listener.Dispose(); - - Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); - - Assert.Equal(1, tokenClient.RequestCount); - - var forwardedRequest = recordingHandler.LastRequest; - Assert.NotNull(forwardedRequest); - Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter); - - Assert.Contains(activationMeasurements, measurement => - measurement.Value == 1 && - measurement.Outcome == "error" && - measurement.Source == "service"); - - Assert.Contains(latencyMeasurements, measurement => - measurement.Outcome == "error" && - measurement.Source == "service"); - } - - [Fact] - public async Task ActivateRevision_RetriesOnTooManyRequests() - { - await using var factory = new PolicyGatewayWebApplicationFactory(); - - var recordedDelays = new List(); - var originalSleep = SystemClock.SleepAsync; - SystemClock.SleepAsync = (delay, cancellationToken) => - { - recordedDelays.Add(delay); - return Task.CompletedTask; - }; - - var tokenClient = factory.Services.GetRequiredService(); - tokenClient.Reset(); - - var recordingHandler = factory.Services.GetRequiredService(); - recordingHandler.Reset(); - recordingHandler.SetResponseSequence(new[] - { - CreateThrottleResponse(), - CreateThrottleResponse(), - RecordingPolicyEngineHandler.CreateSuccessResponse() - }); - - using var client = factory.CreateClient(); - - try - { - var response = await client.PostAsJsonAsync( - "/api/policy/packs/example/revisions/7:activate", - new ActivatePolicyRevisionRequest("retry after throttle")); - - Assert.True(response.IsSuccessStatusCode, "Gateway should succeed after retrying throttled upstream responses."); - Assert.Equal(1, tokenClient.RequestCount); - Assert.Equal(3, recordingHandler.RequestCount); - } - finally - { - SystemClock.SleepAsync = originalSleep; - } - - Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, recordedDelays); - } - - private static HttpResponseMessage CreateThrottleResponse() - { - var problem = new ProblemDetails - { - Title = "Too many requests", - Detail = "Slow down.", - Status = StatusCodes.Status429TooManyRequests - }; - - var response = new HttpResponseMessage((HttpStatusCode)StatusCodes.Status429TooManyRequests) - { - Content = JsonContent.Create(problem) - }; - response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMilliseconds(10)); - return response; - } - - private static string GetTag(ReadOnlySpan> tags, string key) - { - foreach (var tag in tags) - { - if (string.Equals(tag.Key, key, StringComparison.Ordinal)) - { - return tag.Value?.ToString() ?? string.Empty; - } - } - - return string.Empty; - } - - private sealed class PolicyGatewayWebApplicationFactory : WebApplicationFactory - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Development"); - - builder.ConfigureAppConfiguration((_, configurationBuilder) => - { - var settings = new Dictionary - { - ["PolicyGateway:Telemetry:MinimumLogLevel"] = "Warning", - ["PolicyGateway:ResourceServer:Authority"] = "https://authority.test", - ["PolicyGateway:ResourceServer:RequireHttpsMetadata"] = "false", - ["PolicyGateway:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32", - ["PolicyGateway:ResourceServer:BypassNetworks:1"] = "::1/128", - ["PolicyGateway:PolicyEngine:BaseAddress"] = "https://policy-engine.test/", - ["PolicyGateway:PolicyEngine:ClientCredentials:Enabled"] = "true", - ["PolicyGateway:PolicyEngine:ClientCredentials:ClientId"] = "policy-gateway", - ["PolicyGateway:PolicyEngine:ClientCredentials:ClientSecret"] = "secret", - ["PolicyGateway:PolicyEngine:ClientCredentials:Scopes:0"] = "policy:activate", - ["PolicyGateway:PolicyEngine:Dpop:Enabled"] = "false" - }; - - configurationBuilder.AddInMemoryCollection(settings); - }); - - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - - services.RemoveAll(); - services.RemoveAll(); - services.AddSingleton(); - services.AddHttpClient() - .ConfigureHttpClient(client => - { - client.BaseAddress = new Uri("https://policy-engine.test/"); - }) - .ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService()); - - services.AddSingleton(new RemoteIpStartupFilter()); - - services.PostConfigure(StellaOpsAuthenticationDefaults.AuthenticationScheme, options => - { - options.RequireHttpsMetadata = false; - options.Configuration = new OpenIdConnectConfiguration - { - Issuer = "https://authority.test", - TokenEndpoint = "https://authority.test/token" - }; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = false, - ValidateAudience = false, - ValidateIssuerSigningKey = false, - SignatureValidator = (token, parameters) => new JsonWebToken(token) - }; - options.BackchannelHttpHandler = new NoOpBackchannelHandler(); - }); - - }); - } - } - - private sealed class RemoteIpStartupFilter : IStartupFilter - { - public Action Configure(Action next) - { - return app => - { - app.Use(async (context, innerNext) => - { - context.Connection.RemoteIpAddress ??= IPAddress.Loopback; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Polly.Utilities; +using StellaOps.Auth.Client; +using StellaOps.Auth.Abstractions; +using StellaOps.Policy.Gateway.Clients; +using StellaOps.Policy.Gateway.Contracts; +using StellaOps.Policy.Gateway.Options; +using StellaOps.Policy.Gateway.Services; +using Xunit; +using Xunit.Sdk; + +namespace StellaOps.Policy.Gateway.Tests; + +public sealed class GatewayActivationTests +{ + [Fact] + public async Task ActivateRevision_UsesServiceTokenFallback_And_RecordsMetrics() + { + await using var factory = new PolicyGatewayWebApplicationFactory(); + + var tokenClient = factory.Services.GetRequiredService(); + tokenClient.Reset(); + + var recordingHandler = factory.Services.GetRequiredService(); + recordingHandler.Reset(); + + using var listener = new MeterListener(); + var activationMeasurements = new List<(long Value, string Outcome, string Source)>(); + var latencyMeasurements = new List<(double Value, string Outcome, string Source)>(); + + listener.InstrumentPublished += (instrument, meterListener) => + { + if (instrument.Meter.Name != "StellaOps.Policy.Gateway") + { + return; + } + + meterListener.EnableMeasurementEvents(instrument); + }; + + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name != "policy_gateway_activation_requests_total") + { + return; + } + + activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); + }); + + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name != "policy_gateway_activation_latency_ms") + { + return; + } + + latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); + }); + + listener.Start(); + + using var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/api/policy/packs/example/revisions/5:activate", + new ActivatePolicyRevisionRequest("rollout window start")); + + listener.Dispose(); + + var forwardedRequest = recordingHandler.LastRequest; + var issuedTokens = tokenClient.RequestCount; + var responseBody = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + throw new Xunit.Sdk.XunitException( + $"Gateway response was {(int)response.StatusCode} {response.StatusCode}. " + + $"Body: {responseBody}. IssuedTokens: {issuedTokens}. Forwarded: { (forwardedRequest is null ? "no" : "yes") }."); + } + + Assert.Equal(1, tokenClient.RequestCount); + + Assert.NotNull(forwardedRequest); + Assert.Equal(HttpMethod.Post, forwardedRequest!.Method); + Assert.Equal("https://policy-engine.test/api/policy/packs/example/revisions/5:activate", forwardedRequest.RequestUri!.ToString()); + Assert.Equal("Bearer", forwardedRequest.Headers.Authorization?.Scheme); + Assert.Equal("service-token", forwardedRequest.Headers.Authorization?.Parameter); + Assert.False(forwardedRequest.Headers.TryGetValues("DPoP", out _), "Expected no DPoP header when DPoP is disabled."); + + Assert.Contains(activationMeasurements, measurement => + measurement.Value == 1 && + measurement.Outcome == "activated" && + measurement.Source == "service"); + + Assert.Contains(latencyMeasurements, measurement => + measurement.Outcome == "activated" && + measurement.Source == "service"); + } + + [Fact] + public async Task ActivateRevision_CompletesDualControlWorkflow() + { + await using var factory = new PolicyGatewayWebApplicationFactory(); + + var recordingHandler = factory.Services.GetRequiredService(); + recordingHandler.Reset(); + recordingHandler.SetResponseSequence(new[] + { + RecordingPolicyEngineHandler.CreatePendingSecondApprovalResponse(), + RecordingPolicyEngineHandler.CreateDualControlSuccessResponse() + }); + + using var client = factory.CreateClient(); + + var firstResponse = await client.PostAsJsonAsync( + "/api/policy/packs/example/revisions/5:activate", + new ActivatePolicyRevisionRequest("first approval")); + + Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode); + var pendingPayload = await firstResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(pendingPayload); + Assert.Equal("pending_second_approval", pendingPayload!.Status); + Assert.True(pendingPayload.Revision.RequiresTwoPersonApproval); + Assert.Single(pendingPayload.Revision.Approvals); + + var secondResponse = await client.PostAsJsonAsync( + "/api/policy/packs/example/revisions/5:activate", + new ActivatePolicyRevisionRequest("second approval")); + + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + var activatedPayload = await secondResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(activatedPayload); + Assert.Equal("activated", activatedPayload!.Status); + Assert.Equal(2, activatedPayload.Revision.Approvals.Count); + Assert.True(activatedPayload.Revision.RequiresTwoPersonApproval); + + Assert.Equal(2, recordingHandler.RequestCount); + } + + [Fact] + public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsUnauthorized() + { + await using var factory = new PolicyGatewayWebApplicationFactory(); + + var tokenClient = factory.Services.GetRequiredService(); + tokenClient.Reset(); + + var recordingHandler = factory.Services.GetRequiredService(); + recordingHandler.Reset(); + recordingHandler.SetResponseFactory(_ => + { + var problem = new ProblemDetails + { + Title = "Unauthorized", + Detail = "Caller token rejected.", + Status = StatusCodes.Status401Unauthorized + }; + return new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = JsonContent.Create(problem) + }; + }); + + using var listener = new MeterListener(); + var activationMeasurements = new List<(long Value, string Outcome, string Source)>(); + var latencyMeasurements = new List<(double Value, string Outcome, string Source)>(); + + listener.InstrumentPublished += (instrument, meterListener) => + { + if (instrument.Meter.Name != "StellaOps.Policy.Gateway") + { + return; + } + + meterListener.EnableMeasurementEvents(instrument); + }; + + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name != "policy_gateway_activation_requests_total") + { + return; + } + + activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); + }); + + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name != "policy_gateway_activation_latency_ms") + { + return; + } + + latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); + }); + + listener.Start(); + + using var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/api/policy/packs/example/revisions/2:activate", + new ActivatePolicyRevisionRequest("failure path")); + + listener.Dispose(); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + Assert.Equal(1, tokenClient.RequestCount); + + var forwardedRequest = recordingHandler.LastRequest; + Assert.NotNull(forwardedRequest); + Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter); + + Assert.Contains(activationMeasurements, measurement => + measurement.Value == 1 && + measurement.Outcome == "unauthorized" && + measurement.Source == "service"); + + Assert.Contains(latencyMeasurements, measurement => + measurement.Outcome == "unauthorized" && + measurement.Source == "service"); + } + + [Fact] + public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsBadGateway() + { + await using var factory = new PolicyGatewayWebApplicationFactory(); + + var tokenClient = factory.Services.GetRequiredService(); + tokenClient.Reset(); + + var recordingHandler = factory.Services.GetRequiredService(); + recordingHandler.Reset(); + recordingHandler.SetResponseFactory(_ => + { + var problem = new ProblemDetails + { + Title = "Upstream error", + Detail = "Policy Engine returned 502.", + Status = StatusCodes.Status502BadGateway + }; + return new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = JsonContent.Create(problem) + }; + }); + + using var listener = new MeterListener(); + var activationMeasurements = new List<(long Value, string Outcome, string Source)>(); + var latencyMeasurements = new List<(double Value, string Outcome, string Source)>(); + + listener.InstrumentPublished += (instrument, meterListener) => + { + if (instrument.Meter.Name != "StellaOps.Policy.Gateway") + { + return; + } + + meterListener.EnableMeasurementEvents(instrument); + }; + + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name != "policy_gateway_activation_requests_total") + { + return; + } + + activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); + }); + + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name != "policy_gateway_activation_latency_ms") + { + return; + } + + latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source"))); + }); + + listener.Start(); + + using var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/api/policy/packs/example/revisions/3:activate", + new ActivatePolicyRevisionRequest("upstream failure")); + + listener.Dispose(); + + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + + Assert.Equal(1, tokenClient.RequestCount); + + var forwardedRequest = recordingHandler.LastRequest; + Assert.NotNull(forwardedRequest); + Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter); + + Assert.Contains(activationMeasurements, measurement => + measurement.Value == 1 && + measurement.Outcome == "error" && + measurement.Source == "service"); + + Assert.Contains(latencyMeasurements, measurement => + measurement.Outcome == "error" && + measurement.Source == "service"); + } + + [Fact] + public async Task ActivateRevision_RetriesOnTooManyRequests() + { + await using var factory = new PolicyGatewayWebApplicationFactory(); + + var recordedDelays = new List(); + var originalSleep = SystemClock.SleepAsync; + SystemClock.SleepAsync = (delay, cancellationToken) => + { + recordedDelays.Add(delay); + return Task.CompletedTask; + }; + + var tokenClient = factory.Services.GetRequiredService(); + tokenClient.Reset(); + + var recordingHandler = factory.Services.GetRequiredService(); + recordingHandler.Reset(); + recordingHandler.SetResponseSequence(new[] + { + CreateThrottleResponse(), + CreateThrottleResponse(), + RecordingPolicyEngineHandler.CreateSuccessResponse() + }); + + using var client = factory.CreateClient(); + + try + { + var response = await client.PostAsJsonAsync( + "/api/policy/packs/example/revisions/7:activate", + new ActivatePolicyRevisionRequest("retry after throttle")); + + Assert.True(response.IsSuccessStatusCode, "Gateway should succeed after retrying throttled upstream responses."); + Assert.Equal(1, tokenClient.RequestCount); + Assert.Equal(3, recordingHandler.RequestCount); + } + finally + { + SystemClock.SleepAsync = originalSleep; + } + + Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, recordedDelays); + } + + private static HttpResponseMessage CreateThrottleResponse() + { + var problem = new ProblemDetails + { + Title = "Too many requests", + Detail = "Slow down.", + Status = StatusCodes.Status429TooManyRequests + }; + + var response = new HttpResponseMessage((HttpStatusCode)StatusCodes.Status429TooManyRequests) + { + Content = JsonContent.Create(problem) + }; + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMilliseconds(10)); + return response; + } + + private static string GetTag(ReadOnlySpan> tags, string key) + { + foreach (var tag in tags) + { + if (string.Equals(tag.Key, key, StringComparison.Ordinal)) + { + return tag.Value?.ToString() ?? string.Empty; + } + } + + return string.Empty; + } + + private sealed class PolicyGatewayWebApplicationFactory : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + + builder.ConfigureAppConfiguration((_, configurationBuilder) => + { + var settings = new Dictionary + { + ["PolicyGateway:Telemetry:MinimumLogLevel"] = "Warning", + ["PolicyGateway:ResourceServer:Authority"] = "https://authority.test", + ["PolicyGateway:ResourceServer:RequireHttpsMetadata"] = "false", + ["PolicyGateway:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32", + ["PolicyGateway:ResourceServer:BypassNetworks:1"] = "::1/128", + ["PolicyGateway:PolicyEngine:BaseAddress"] = "https://policy-engine.test/", + ["PolicyGateway:PolicyEngine:ClientCredentials:Enabled"] = "true", + ["PolicyGateway:PolicyEngine:ClientCredentials:ClientId"] = "policy-gateway", + ["PolicyGateway:PolicyEngine:ClientCredentials:ClientSecret"] = "secret", + ["PolicyGateway:PolicyEngine:ClientCredentials:Scopes:0"] = "policy:activate", + ["PolicyGateway:PolicyEngine:Dpop:Enabled"] = "false" + }; + + configurationBuilder.AddInMemoryCollection(settings); + }); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(); + services.AddHttpClient() + .ConfigureHttpClient(client => + { + client.BaseAddress = new Uri("https://policy-engine.test/"); + }) + .ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService()); + + services.AddSingleton(new RemoteIpStartupFilter()); + + services.PostConfigure(StellaOpsAuthenticationDefaults.AuthenticationScheme, options => + { + options.RequireHttpsMetadata = false; + options.Configuration = new OpenIdConnectConfiguration + { + Issuer = "https://authority.test", + TokenEndpoint = "https://authority.test/token" + }; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateIssuerSigningKey = false, + SignatureValidator = (token, parameters) => new JsonWebToken(token) + }; + options.BackchannelHttpHandler = new NoOpBackchannelHandler(); + }); + + }); + } + } + + private sealed class RemoteIpStartupFilter : IStartupFilter + { + public Action Configure(Action next) + { + return app => + { + app.Use(async (context, innerNext) => + { + context.Connection.RemoteIpAddress ??= IPAddress.Loopback; await innerNext(); - }); - - next(app); - }; - } - } - - private sealed class RecordingPolicyEngineHandler : HttpMessageHandler - { - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); - - public HttpRequestMessage? LastRequest { get; private set; } - public int RequestCount { get; private set; } - private Func? responseFactory; - private Queue? responseQueue; - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - LastRequest = request; - RequestCount++; - - if (responseQueue is { Count: > 0 }) - { - return Task.FromResult(responseQueue.Dequeue()); - } - - var response = responseFactory is not null - ? responseFactory(request) - : CreateSuccessResponse(); - - return Task.FromResult(response); - } - - public void Reset() - { - LastRequest = null; - RequestCount = 0; - responseFactory = null; - responseQueue?.Clear(); - responseQueue = null; - } - - public void SetResponseFactory(Func? factory) - { - responseFactory = factory; - } - - public void SetResponseSequence(IEnumerable responses) - { - responseQueue = new Queue(responses ?? Array.Empty()); - } - - public static HttpResponseMessage CreateSuccessResponse() - { - var now = DateTimeOffset.UtcNow; - var payload = new PolicyRevisionActivationDto( - "activated", - new PolicyRevisionDto( - 5, - "activated", - false, - now, - now, - Array.Empty())); - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = JsonContent.Create(payload, options: SerializerOptions) - }; - } - } - - private sealed class NoOpBackchannelHandler : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); - } - - private sealed class StubTokenClient : IStellaOpsTokenClient - { - public int RequestCount { get; private set; } - - public void Reset() - { - RequestCount = 0; - } - - public Task RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary? additionalParameters = null, CancellationToken cancellationToken = default) - { - RequestCount++; - var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); - return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", expiresAt, Array.Empty())); - } - - public Task RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary? additionalParameters = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public Task GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public ValueTask GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) - => ValueTask.FromResult(null); - - public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - } -} + }); + + next(app); + }; + } + } + + private sealed class RecordingPolicyEngineHandler : HttpMessageHandler + { + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public HttpRequestMessage? LastRequest { get; private set; } + public int RequestCount { get; private set; } + private Func? responseFactory; + private Queue? responseQueue; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + RequestCount++; + + if (responseQueue is { Count: > 0 }) + { + return Task.FromResult(responseQueue.Dequeue()); + } + + var response = responseFactory is not null + ? responseFactory(request) + : CreateSuccessResponse(); + + return Task.FromResult(response); + } + + public void Reset() + { + LastRequest = null; + RequestCount = 0; + responseFactory = null; + responseQueue?.Clear(); + responseQueue = null; + } + + public void SetResponseFactory(Func? factory) + { + responseFactory = factory; + } + + public void SetResponseSequence(IEnumerable responses) + { + responseQueue = new Queue(responses ?? Array.Empty()); + } + + public static HttpResponseMessage CreateSuccessResponse() + => CreateActivationResponse( + HttpStatusCode.OK, + "activated", + "activated", + false, + DateTimeOffset.UtcNow, + Array.Empty()); + + public static HttpResponseMessage CreatePendingSecondApprovalResponse() + { + var firstApproval = new PolicyActivationApprovalDto( + "alice@example.com", + DateTimeOffset.UtcNow, + "first approval"); + + return CreateActivationResponse( + HttpStatusCode.Accepted, + "pending_second_approval", + "approved", + true, + activatedAt: null, + approvals: new[] { firstApproval }); + } + + public static HttpResponseMessage CreateDualControlSuccessResponse() + { + var approvals = new[] + { + new PolicyActivationApprovalDto("alice@example.com", DateTimeOffset.UtcNow.AddSeconds(-15), "first approval"), + new PolicyActivationApprovalDto("bob@example.com", DateTimeOffset.UtcNow, "final approval") + }; + + return CreateActivationResponse( + HttpStatusCode.OK, + "activated", + "activated", + true, + DateTimeOffset.UtcNow, + approvals); + } + + private static HttpResponseMessage CreateActivationResponse( + HttpStatusCode httpStatus, + string activationStatus, + string revisionStatus, + bool requiresTwoPersonApproval, + DateTimeOffset? activatedAt, + IReadOnlyList? approvals) + { + var now = DateTimeOffset.UtcNow; + var payload = new PolicyRevisionActivationDto( + activationStatus, + new PolicyRevisionDto( + 5, + revisionStatus, + requiresTwoPersonApproval, + now, + activatedAt, + approvals ?? Array.Empty())); + + return new HttpResponseMessage(httpStatus) + { + Content = JsonContent.Create(payload, options: SerializerOptions) + }; + } +} + + private sealed class NoOpBackchannelHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + private sealed class StubTokenClient : IStellaOpsTokenClient + { + public int RequestCount { get; private set; } + + public void Reset() + { + RequestCount = 0; + } + + public Task RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary? additionalParameters = null, CancellationToken cancellationToken = default) + { + RequestCount++; + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); + return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", expiresAt, Array.Empty())); + } + + public Task RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary? additionalParameters = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ValueTask GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) + => ValueTask.FromResult(null); + + public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } +} diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Cas/LocalCasClient.cs b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Cas/LocalCasClient.cs index 0bb157a2e..200dd7231 100644 --- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Cas/LocalCasClient.cs +++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Cas/LocalCasClient.cs @@ -1,8 +1,8 @@ using System; using System.IO; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Cryptography; namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; @@ -11,23 +11,25 @@ namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; /// public sealed class LocalCasClient { - private readonly string rootDirectory; - private readonly string algorithm; - - public LocalCasClient(LocalCasOptions options) - { - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - algorithm = options.Algorithm.ToLowerInvariant(); + private readonly string rootDirectory; + private readonly string algorithm; + private readonly ICryptoHash hash; + + public LocalCasClient(LocalCasOptions options, ICryptoHash hash) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + this.hash = hash ?? throw new ArgumentNullException(nameof(hash)); + + algorithm = options.Algorithm.ToLowerInvariant(); if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("Only the sha256 algorithm is supported.", nameof(options)); } - rootDirectory = Path.GetFullPath(options.RootDirectory); + rootDirectory = Path.GetFullPath(options.RootDirectory); } public Task VerifyWriteAsync(CancellationToken cancellationToken) @@ -65,10 +67,8 @@ public sealed class LocalCasClient return Path.Combine(rootDirectory, algorithm, prefix, $"{suffix}.bin"); } - private static string ComputeDigest(ReadOnlySpan content) - { - Span buffer = stackalloc byte[32]; - SHA256.HashData(content, buffer); - return Convert.ToHexString(buffer).ToLowerInvariant(); - } -} + private string ComputeDigest(ReadOnlySpan content) + { + return hash.ComputeHashHex(content, HashAlgorithms.Sha256); + } +} diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Descriptor/DescriptorGenerator.cs b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Descriptor/DescriptorGenerator.cs index d0d4abd74..ce97e1c15 100644 --- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Descriptor/DescriptorGenerator.cs +++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Descriptor/DescriptorGenerator.cs @@ -2,10 +2,10 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Cryptography; namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; @@ -16,13 +16,14 @@ public sealed class DescriptorGenerator { public const string Schema = "stellaops.buildx.descriptor.v1"; - private readonly TimeProvider timeProvider; - - public DescriptorGenerator(TimeProvider timeProvider) - { - timeProvider ??= TimeProvider.System; - this.timeProvider = timeProvider; - } + private readonly TimeProvider _timeProvider; + private readonly ICryptoHash _hash; + + public DescriptorGenerator(TimeProvider timeProvider, ICryptoHash hash) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); + } public async Task CreateAsync(DescriptorRequest request, CancellationToken cancellationToken) { @@ -78,7 +79,7 @@ public sealed class DescriptorGenerator return new DescriptorDocument( Schema: Schema, - GeneratedAt: timeProvider.GetUtcNow(), + GeneratedAt: _timeProvider.GetUtcNow(), Generator: generatorMetadata, Subject: subject, Artifact: artifact, @@ -86,7 +87,7 @@ public sealed class DescriptorGenerator Metadata: metadata); } - private static string ComputeDeterministicNonce(DescriptorRequest request, FileInfo sbomFile, string sbomDigest) + private string ComputeDeterministicNonce(DescriptorRequest request, FileInfo sbomFile, string sbomDigest) { var builder = new StringBuilder(); builder.AppendLine("stellaops.buildx.nonce.v1"); @@ -108,45 +109,33 @@ public sealed class DescriptorGenerator builder.AppendLine(request.PredicateType); var payload = Encoding.UTF8.GetBytes(builder.ToString()); - Span hash = stackalloc byte[32]; - SHA256.HashData(payload, hash); - + var digest = _hash.ComputeHash(payload, HashAlgorithms.Sha256); Span nonceBytes = stackalloc byte[16]; - hash[..16].CopyTo(nonceBytes); + digest.AsSpan(0, 16).CopyTo(nonceBytes); return Convert.ToHexString(nonceBytes).ToLowerInvariant(); } - private static async Task ComputeFileDigestAsync(FileInfo file, CancellationToken cancellationToken) - { - await using var stream = new FileStream( - file.FullName, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: 128 * 1024, - FileOptions.Asynchronous | FileOptions.SequentialScan); - - using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - - var buffer = new byte[128 * 1024]; - int bytesRead; - while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) - { - hash.AppendData(buffer, 0, bytesRead); - } - - var digest = hash.GetHashAndReset(); - return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}"; - } - - private static string ComputeExpectedDsseDigest(string imageDigest, string sbomDigest, string nonce) - { - var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}"; - var bytes = System.Text.Encoding.UTF8.GetBytes(payload); - Span hash = stackalloc byte[32]; - SHA256.HashData(bytes, hash); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } + private async Task ComputeFileDigestAsync(FileInfo file, CancellationToken cancellationToken) + { + await using var stream = new FileStream( + file.FullName, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 128 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + var digest = await _hash.ComputeHashAsync(stream, HashAlgorithms.Sha256, cancellationToken).ConfigureAwait(false); + return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}"; + } + + private string ComputeExpectedDsseDigest(string imageDigest, string sbomDigest, string nonce) + { + var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}"; + var bytes = Encoding.UTF8.GetBytes(payload); + var digest = _hash.ComputeHash(bytes, HashAlgorithms.Sha256); + return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}"; + } private static IReadOnlyDictionary BuildArtifactAnnotations(DescriptorRequest request, string nonce, string expectedDsse) { diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Program.cs b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Program.cs index 0cb5c0a08..c407b899c 100644 --- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Program.cs +++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Program.cs @@ -1,438 +1,440 @@ -using System; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text.Json.Serialization; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface; - -namespace StellaOps.Scanner.Sbomer.BuildXPlugin; - -internal static class Program -{ - private static readonly JsonSerializerOptions ManifestPrintOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private static readonly JsonSerializerOptions DescriptorJsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private static async Task Main(string[] args) - { - using var cancellation = new CancellationTokenSource(); - Console.CancelKeyPress += (_, eventArgs) => - { - eventArgs.Cancel = true; - cancellation.Cancel(); - }; - - var command = args.Length > 0 ? args[0].ToLowerInvariant() : "handshake"; - var commandArgs = args.Skip(1).ToArray(); - - try - { - return command switch - { - "handshake" => await RunHandshakeAsync(commandArgs, cancellation.Token).ConfigureAwait(false), - "manifest" => await RunManifestAsync(commandArgs, cancellation.Token).ConfigureAwait(false), - "descriptor" or "annotate" => await RunDescriptorAsync(commandArgs, cancellation.Token).ConfigureAwait(false), - "version" => RunVersion(), - "help" or "--help" or "-h" => PrintHelp(), - _ => UnknownCommand(command) - }; - } - catch (OperationCanceledException) - { - Console.Error.WriteLine("Operation cancelled."); - return 130; - } - catch (BuildxPluginException ex) - { - Console.Error.WriteLine(ex.Message); - return 2; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Unhandled error: {ex}"); - return 1; - } - } - - private static async Task RunHandshakeAsync(string[] args, CancellationToken cancellationToken) - { - var manifestDirectory = ResolveManifestDirectory(args); - var loader = new BuildxPluginManifestLoader(manifestDirectory); - var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false); - - var casRoot = ResolveCasRoot(args, manifest); - var casClient = new LocalCasClient(new LocalCasOptions - { - RootDirectory = casRoot, - Algorithm = "sha256" - }); - - var result = await casClient.VerifyWriteAsync(cancellationToken).ConfigureAwait(false); - - Console.WriteLine($"handshake ok: {manifest.Id}@{manifest.Version} → {result.Algorithm}:{result.Digest}"); - Console.WriteLine(result.Path); - return 0; - } - - private static async Task RunManifestAsync(string[] args, CancellationToken cancellationToken) - { - var manifestDirectory = ResolveManifestDirectory(args); - var loader = new BuildxPluginManifestLoader(manifestDirectory); - var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false); - - var json = JsonSerializer.Serialize(manifest, ManifestPrintOptions); - Console.WriteLine(json); - return 0; - } - - private static int RunVersion() - { - var assembly = Assembly.GetExecutingAssembly(); - var version = assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString() - ?? "unknown"; - Console.WriteLine(version); - return 0; - } - - private static int PrintHelp() - { - Console.WriteLine("StellaOps BuildX SBOM generator"); - Console.WriteLine("Usage:"); - Console.WriteLine(" stellaops-buildx [handshake|manifest|descriptor|version]"); - Console.WriteLine(); - Console.WriteLine("Commands:"); - Console.WriteLine(" handshake Probe the local CAS and ensure manifests are discoverable."); - Console.WriteLine(" manifest Print the resolved manifest JSON."); - Console.WriteLine(" descriptor Emit OCI descriptor + provenance placeholder for the provided SBOM."); - Console.WriteLine(" version Print the plug-in version."); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --manifest Override the manifest directory."); - Console.WriteLine(" --cas Override the CAS root directory."); - Console.WriteLine(" --image (descriptor) Image digest the SBOM belongs to."); - Console.WriteLine(" --sbom (descriptor) Path to the SBOM file to describe."); - Console.WriteLine(" --attestor (descriptor) Optional Attestor endpoint for provenance placeholders."); - Console.WriteLine(" --attestor-token Bearer token for Attestor requests (or STELLAOPS_ATTESTOR_TOKEN)."); - Console.WriteLine(" --attestor-insecure Skip TLS verification for Attestor requests (dev/test only)."); - Console.WriteLine(" --surface-layer-fragments Persist layer fragments JSON into Surface.FS."); - Console.WriteLine(" --surface-entrytrace-graph Persist EntryTrace graph JSON into Surface.FS."); - Console.WriteLine(" --surface-entrytrace-ndjson Persist EntryTrace NDJSON into Surface.FS."); - Console.WriteLine(" --surface-cache-root Override Surface cache root (defaults to CAS root)."); - Console.WriteLine(" --surface-bucket Bucket name used in Surface CAS URIs (default scanner-artifacts)."); - Console.WriteLine(" --surface-tenant Tenant identifier recorded in the Surface manifest."); - return 0; - } - - private static int UnknownCommand(string command) - { - Console.Error.WriteLine($"Unknown command '{command}'. Use 'help' for usage."); - return 1; - } - - private static string ResolveManifestDirectory(string[] args) - { - var explicitPath = GetOption(args, "--manifest") - ?? Environment.GetEnvironmentVariable("STELLAOPS_BUILDX_MANIFEST_DIR"); - - if (!string.IsNullOrWhiteSpace(explicitPath)) - { - return Path.GetFullPath(explicitPath); - } - - var defaultDirectory = Path.Combine(AppContext.BaseDirectory, "plugins", "scanner", "buildx"); - if (Directory.Exists(defaultDirectory)) - { - return defaultDirectory; - } - - return AppContext.BaseDirectory; - } - - private static string ResolveCasRoot(string[] args, BuildxPluginManifest manifest) - { - var overrideValue = GetOption(args, "--cas") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_CAS_ROOT"); - - if (!string.IsNullOrWhiteSpace(overrideValue)) - { - return Path.GetFullPath(overrideValue); - } - - var manifestDefault = manifest.Cas.DefaultRoot; - if (!string.IsNullOrWhiteSpace(manifestDefault)) - { - if (Path.IsPathRooted(manifestDefault)) - { - return Path.GetFullPath(manifestDefault); - } - - var baseDirectory = manifest.SourceDirectory ?? AppContext.BaseDirectory; - return Path.GetFullPath(Path.Combine(baseDirectory, manifestDefault)); - } - - return Path.Combine(AppContext.BaseDirectory, "cas"); - } - - private static async Task RunDescriptorAsync(string[] args, CancellationToken cancellationToken) - { - var manifestDirectory = ResolveManifestDirectory(args); - var loader = new BuildxPluginManifestLoader(manifestDirectory); - var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false); - var casRoot = ResolveCasRoot(args, manifest); - - var imageDigest = RequireOption(args, "--image"); - var sbomPath = RequireOption(args, "--sbom"); - - var sbomMediaType = GetOption(args, "--media-type") ?? "application/vnd.cyclonedx+json"; - var sbomFormat = GetOption(args, "--sbom-format") ?? "cyclonedx-json"; - var sbomKind = GetOption(args, "--sbom-kind") ?? "inventory"; - var artifactType = GetOption(args, "--artifact-type") ?? "application/vnd.stellaops.sbom.layer+json"; - var subjectMediaType = GetOption(args, "--subject-media-type") ?? "application/vnd.oci.image.manifest.v1+json"; - var predicateType = GetOption(args, "--predicate-type") ?? "https://slsa.dev/provenance/v1"; - var licenseId = GetOption(args, "--license-id") ?? Environment.GetEnvironmentVariable("STELLAOPS_LICENSE_ID"); - var repository = GetOption(args, "--repository"); - var buildRef = GetOption(args, "--build-ref"); - var sbomName = GetOption(args, "--sbom-name") ?? Path.GetFileName(sbomPath); - - var attestorUriText = GetOption(args, "--attestor") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_URL"); - var attestorToken = GetOption(args, "--attestor-token") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_TOKEN"); - var attestorInsecure = GetFlag(args, "--attestor-insecure") - || string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_INSECURE"), "true", StringComparison.OrdinalIgnoreCase); - Uri? attestorUri = null; - if (!string.IsNullOrWhiteSpace(attestorUriText)) - { - attestorUri = new Uri(attestorUriText, UriKind.Absolute); - } - - var assembly = Assembly.GetExecutingAssembly(); - var version = assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString() - ?? "0.0.0"; - - var request = new DescriptorRequest - { - ImageDigest = imageDigest, - SbomPath = sbomPath, - SbomMediaType = sbomMediaType, - SbomFormat = sbomFormat, - SbomKind = sbomKind, - SbomArtifactType = artifactType, - SubjectMediaType = subjectMediaType, - PredicateType = predicateType, - GeneratorVersion = version, - GeneratorName = assembly.GetName().Name, - LicenseId = licenseId, - SbomName = sbomName, - Repository = repository, - BuildRef = buildRef, - AttestorUri = attestorUri?.ToString() - }.Validate(); - - var generator = new DescriptorGenerator(TimeProvider.System); - var document = await generator.CreateAsync(request, cancellationToken).ConfigureAwait(false); - - if (attestorUri is not null) - { - using var httpClient = CreateAttestorHttpClient(attestorUri, attestorToken, attestorInsecure); - var attestorClient = new AttestorClient(httpClient); - await attestorClient.SendPlaceholderAsync(attestorUri, document, cancellationToken).ConfigureAwait(false); - } - - await TryPublishSurfaceArtifactsAsync(args, request, casRoot, version, cancellationToken).ConfigureAwait(false); - - var json = JsonSerializer.Serialize(document, DescriptorJsonOptions); - Console.WriteLine(json); - return 0; - } - - private static async Task TryPublishSurfaceArtifactsAsync( - string[] args, - DescriptorRequest descriptorRequest, - string casRoot, - string generatorVersion, - CancellationToken cancellationToken) - { - var surfaceOptions = ResolveSurfaceOptions(args, descriptorRequest, casRoot, generatorVersion); - if (surfaceOptions is null || !surfaceOptions.HasArtifacts) - { - return; - } - - var writer = new SurfaceManifestWriter(TimeProvider.System); - var result = await writer.WriteAsync(surfaceOptions, cancellationToken).ConfigureAwait(false); - if (result is null) - { - return; - } - - Console.Error.WriteLine($"surface manifest stored: {result.ManifestUri} ({result.Document.Artifacts.Count} artefacts)"); - } - - private static SurfaceOptions? ResolveSurfaceOptions( - string[] args, - DescriptorRequest descriptorRequest, - string casRoot, - string generatorVersion) - { - var layerFragmentsPath = GetOption(args, "--surface-layer-fragments") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_LAYER_FRAGMENTS"); - var entryTraceGraphPath = GetOption(args, "--surface-entrytrace-graph") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_GRAPH"); - var entryTraceNdjsonPath = GetOption(args, "--surface-entrytrace-ndjson") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_NDJSON"); - - if (string.IsNullOrWhiteSpace(layerFragmentsPath) && - string.IsNullOrWhiteSpace(entryTraceGraphPath) && - string.IsNullOrWhiteSpace(entryTraceNdjsonPath)) - { - return null; - } - - var cacheRoot = GetOption(args, "--surface-cache-root") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_CACHE_ROOT") - ?? casRoot; - var bucket = GetOption(args, "--surface-bucket") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_BUCKET") - ?? SurfaceCasLayout.DefaultBucket; - var rootPrefix = GetOption(args, "--surface-root-prefix") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ROOT_PREFIX") - ?? SurfaceCasLayout.DefaultRootPrefix; - var tenant = GetOption(args, "--surface-tenant") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_TENANT") - ?? "default"; - var component = GetOption(args, "--surface-component") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT") - ?? "scanner.buildx"; - var componentVersion = GetOption(args, "--surface-component-version") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT_VERSION") - ?? generatorVersion; - var workerInstance = GetOption(args, "--surface-worker-instance") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_WORKER_INSTANCE") - ?? Environment.MachineName; - var attemptValue = GetOption(args, "--surface-attempt") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ATTEMPT"); - var attempt = 1; - if (!string.IsNullOrWhiteSpace(attemptValue) && int.TryParse(attemptValue, out var parsedAttempt) && parsedAttempt > 0) - { - attempt = parsedAttempt; - } - - var scanId = GetOption(args, "--surface-scan-id") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_SCAN_ID") - ?? descriptorRequest.SbomName - ?? descriptorRequest.ImageDigest; - - var manifestOutput = GetOption(args, "--surface-manifest-output") - ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_MANIFEST_OUTPUT"); - - return new SurfaceOptions( - CacheRoot: cacheRoot, - CacheBucket: bucket, - RootPrefix: rootPrefix, - Tenant: tenant, - Component: component, - ComponentVersion: componentVersion, - WorkerInstance: workerInstance, - Attempt: attempt, - ImageDigest: descriptorRequest.ImageDigest, - ScanId: scanId, - LayerFragmentsPath: layerFragmentsPath, - EntryTraceGraphPath: entryTraceGraphPath, - EntryTraceNdjsonPath: entryTraceNdjsonPath, - ManifestOutputPath: manifestOutput); - } - - private static string? GetOption(string[] args, string optionName) - { - for (var i = 0; i < args.Length; i++) - { - var argument = args[i]; - if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase)) - { - if (i + 1 >= args.Length) - { - throw new BuildxPluginException($"Option '{optionName}' requires a value."); - } - - return args[i + 1]; - } - - if (argument.StartsWith(optionName + "=", StringComparison.OrdinalIgnoreCase)) - { - return argument[(optionName.Length + 1)..]; - } - } - - return null; - } - - private static bool GetFlag(string[] args, string optionName) - { - foreach (var argument in args) - { - if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - private static string RequireOption(string[] args, string optionName) - { - var value = GetOption(args, optionName); - if (string.IsNullOrWhiteSpace(value)) - { - throw new BuildxPluginException($"Option '{optionName}' is required."); - } - - return value; - } - - private static HttpClient CreateAttestorHttpClient(Uri attestorUri, string? bearerToken, bool insecure) - { - var handler = new HttpClientHandler - { - CheckCertificateRevocationList = true, - }; - - if (insecure && string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { -#pragma warning disable S4830 // Explicitly gated by --attestor-insecure flag/env for dev/test usage. - handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; -#pragma warning restore S4830 - } - - var client = new HttpClient(handler, disposeHandler: true) - { - Timeout = TimeSpan.FromSeconds(30) - }; - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - if (!string.IsNullOrWhiteSpace(bearerToken)) - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); - } - - return client; - } -} +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json.Serialization; +using StellaOps.Cryptography; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface; + +namespace StellaOps.Scanner.Sbomer.BuildXPlugin; + +internal static class Program +{ + private static readonly JsonSerializerOptions ManifestPrintOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly JsonSerializerOptions DescriptorJsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static async Task Main(string[] args) + { + using var cancellation = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + eventArgs.Cancel = true; + cancellation.Cancel(); + }; + + var command = args.Length > 0 ? args[0].ToLowerInvariant() : "handshake"; + var commandArgs = args.Skip(1).ToArray(); + + try + { + return command switch + { + "handshake" => await RunHandshakeAsync(commandArgs, cancellation.Token).ConfigureAwait(false), + "manifest" => await RunManifestAsync(commandArgs, cancellation.Token).ConfigureAwait(false), + "descriptor" or "annotate" => await RunDescriptorAsync(commandArgs, cancellation.Token).ConfigureAwait(false), + "version" => RunVersion(), + "help" or "--help" or "-h" => PrintHelp(), + _ => UnknownCommand(command) + }; + } + catch (OperationCanceledException) + { + Console.Error.WriteLine("Operation cancelled."); + return 130; + } + catch (BuildxPluginException ex) + { + Console.Error.WriteLine(ex.Message); + return 2; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unhandled error: {ex}"); + return 1; + } + } + + private static async Task RunHandshakeAsync(string[] args, CancellationToken cancellationToken) + { + var manifestDirectory = ResolveManifestDirectory(args); + var loader = new BuildxPluginManifestLoader(manifestDirectory); + var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false); + + var casRoot = ResolveCasRoot(args, manifest); + var hash = CryptoHashFactory.CreateDefault(); + var casClient = new LocalCasClient(new LocalCasOptions + { + RootDirectory = casRoot, + Algorithm = "sha256" + }, hash); + + var result = await casClient.VerifyWriteAsync(cancellationToken).ConfigureAwait(false); + + Console.WriteLine($"handshake ok: {manifest.Id}@{manifest.Version} → {result.Algorithm}:{result.Digest}"); + Console.WriteLine(result.Path); + return 0; + } + + private static async Task RunManifestAsync(string[] args, CancellationToken cancellationToken) + { + var manifestDirectory = ResolveManifestDirectory(args); + var loader = new BuildxPluginManifestLoader(manifestDirectory); + var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false); + + var json = JsonSerializer.Serialize(manifest, ManifestPrintOptions); + Console.WriteLine(json); + return 0; + } + + private static int RunVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + Console.WriteLine(version); + return 0; + } + + private static int PrintHelp() + { + Console.WriteLine("StellaOps BuildX SBOM generator"); + Console.WriteLine("Usage:"); + Console.WriteLine(" stellaops-buildx [handshake|manifest|descriptor|version]"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + Console.WriteLine(" handshake Probe the local CAS and ensure manifests are discoverable."); + Console.WriteLine(" manifest Print the resolved manifest JSON."); + Console.WriteLine(" descriptor Emit OCI descriptor + provenance placeholder for the provided SBOM."); + Console.WriteLine(" version Print the plug-in version."); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" --manifest Override the manifest directory."); + Console.WriteLine(" --cas Override the CAS root directory."); + Console.WriteLine(" --image (descriptor) Image digest the SBOM belongs to."); + Console.WriteLine(" --sbom (descriptor) Path to the SBOM file to describe."); + Console.WriteLine(" --attestor (descriptor) Optional Attestor endpoint for provenance placeholders."); + Console.WriteLine(" --attestor-token Bearer token for Attestor requests (or STELLAOPS_ATTESTOR_TOKEN)."); + Console.WriteLine(" --attestor-insecure Skip TLS verification for Attestor requests (dev/test only)."); + Console.WriteLine(" --surface-layer-fragments Persist layer fragments JSON into Surface.FS."); + Console.WriteLine(" --surface-entrytrace-graph Persist EntryTrace graph JSON into Surface.FS."); + Console.WriteLine(" --surface-entrytrace-ndjson Persist EntryTrace NDJSON into Surface.FS."); + Console.WriteLine(" --surface-cache-root Override Surface cache root (defaults to CAS root)."); + Console.WriteLine(" --surface-bucket Bucket name used in Surface CAS URIs (default scanner-artifacts)."); + Console.WriteLine(" --surface-tenant Tenant identifier recorded in the Surface manifest."); + return 0; + } + + private static int UnknownCommand(string command) + { + Console.Error.WriteLine($"Unknown command '{command}'. Use 'help' for usage."); + return 1; + } + + private static string ResolveManifestDirectory(string[] args) + { + var explicitPath = GetOption(args, "--manifest") + ?? Environment.GetEnvironmentVariable("STELLAOPS_BUILDX_MANIFEST_DIR"); + + if (!string.IsNullOrWhiteSpace(explicitPath)) + { + return Path.GetFullPath(explicitPath); + } + + var defaultDirectory = Path.Combine(AppContext.BaseDirectory, "plugins", "scanner", "buildx"); + if (Directory.Exists(defaultDirectory)) + { + return defaultDirectory; + } + + return AppContext.BaseDirectory; + } + + private static string ResolveCasRoot(string[] args, BuildxPluginManifest manifest) + { + var overrideValue = GetOption(args, "--cas") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_CAS_ROOT"); + + if (!string.IsNullOrWhiteSpace(overrideValue)) + { + return Path.GetFullPath(overrideValue); + } + + var manifestDefault = manifest.Cas.DefaultRoot; + if (!string.IsNullOrWhiteSpace(manifestDefault)) + { + if (Path.IsPathRooted(manifestDefault)) + { + return Path.GetFullPath(manifestDefault); + } + + var baseDirectory = manifest.SourceDirectory ?? AppContext.BaseDirectory; + return Path.GetFullPath(Path.Combine(baseDirectory, manifestDefault)); + } + + return Path.Combine(AppContext.BaseDirectory, "cas"); + } + + private static async Task RunDescriptorAsync(string[] args, CancellationToken cancellationToken) + { + var manifestDirectory = ResolveManifestDirectory(args); + var loader = new BuildxPluginManifestLoader(manifestDirectory); + var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false); + var casRoot = ResolveCasRoot(args, manifest); + + var imageDigest = RequireOption(args, "--image"); + var sbomPath = RequireOption(args, "--sbom"); + + var sbomMediaType = GetOption(args, "--media-type") ?? "application/vnd.cyclonedx+json"; + var sbomFormat = GetOption(args, "--sbom-format") ?? "cyclonedx-json"; + var sbomKind = GetOption(args, "--sbom-kind") ?? "inventory"; + var artifactType = GetOption(args, "--artifact-type") ?? "application/vnd.stellaops.sbom.layer+json"; + var subjectMediaType = GetOption(args, "--subject-media-type") ?? "application/vnd.oci.image.manifest.v1+json"; + var predicateType = GetOption(args, "--predicate-type") ?? "https://slsa.dev/provenance/v1"; + var licenseId = GetOption(args, "--license-id") ?? Environment.GetEnvironmentVariable("STELLAOPS_LICENSE_ID"); + var repository = GetOption(args, "--repository"); + var buildRef = GetOption(args, "--build-ref"); + var sbomName = GetOption(args, "--sbom-name") ?? Path.GetFileName(sbomPath); + + var attestorUriText = GetOption(args, "--attestor") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_URL"); + var attestorToken = GetOption(args, "--attestor-token") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_TOKEN"); + var attestorInsecure = GetFlag(args, "--attestor-insecure") + || string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_INSECURE"), "true", StringComparison.OrdinalIgnoreCase); + Uri? attestorUri = null; + if (!string.IsNullOrWhiteSpace(attestorUriText)) + { + attestorUri = new Uri(attestorUriText, UriKind.Absolute); + } + + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + var request = new DescriptorRequest + { + ImageDigest = imageDigest, + SbomPath = sbomPath, + SbomMediaType = sbomMediaType, + SbomFormat = sbomFormat, + SbomKind = sbomKind, + SbomArtifactType = artifactType, + SubjectMediaType = subjectMediaType, + PredicateType = predicateType, + GeneratorVersion = version, + GeneratorName = assembly.GetName().Name, + LicenseId = licenseId, + SbomName = sbomName, + Repository = repository, + BuildRef = buildRef, + AttestorUri = attestorUri?.ToString() + }.Validate(); + + var generator = new DescriptorGenerator(TimeProvider.System, CryptoHashFactory.CreateDefault()); + var document = await generator.CreateAsync(request, cancellationToken).ConfigureAwait(false); + + if (attestorUri is not null) + { + using var httpClient = CreateAttestorHttpClient(attestorUri, attestorToken, attestorInsecure); + var attestorClient = new AttestorClient(httpClient); + await attestorClient.SendPlaceholderAsync(attestorUri, document, cancellationToken).ConfigureAwait(false); + } + + await TryPublishSurfaceArtifactsAsync(args, request, casRoot, version, cancellationToken).ConfigureAwait(false); + + var json = JsonSerializer.Serialize(document, DescriptorJsonOptions); + Console.WriteLine(json); + return 0; + } + + private static async Task TryPublishSurfaceArtifactsAsync( + string[] args, + DescriptorRequest descriptorRequest, + string casRoot, + string generatorVersion, + CancellationToken cancellationToken) + { + var surfaceOptions = ResolveSurfaceOptions(args, descriptorRequest, casRoot, generatorVersion); + if (surfaceOptions is null || !surfaceOptions.HasArtifacts) + { + return; + } + + var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault()); + var result = await writer.WriteAsync(surfaceOptions, cancellationToken).ConfigureAwait(false); + if (result is null) + { + return; + } + + Console.Error.WriteLine($"surface manifest stored: {result.ManifestUri} ({result.Document.Artifacts.Count} artefacts)"); + } + + private static SurfaceOptions? ResolveSurfaceOptions( + string[] args, + DescriptorRequest descriptorRequest, + string casRoot, + string generatorVersion) + { + var layerFragmentsPath = GetOption(args, "--surface-layer-fragments") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_LAYER_FRAGMENTS"); + var entryTraceGraphPath = GetOption(args, "--surface-entrytrace-graph") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_GRAPH"); + var entryTraceNdjsonPath = GetOption(args, "--surface-entrytrace-ndjson") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_NDJSON"); + + if (string.IsNullOrWhiteSpace(layerFragmentsPath) && + string.IsNullOrWhiteSpace(entryTraceGraphPath) && + string.IsNullOrWhiteSpace(entryTraceNdjsonPath)) + { + return null; + } + + var cacheRoot = GetOption(args, "--surface-cache-root") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_CACHE_ROOT") + ?? casRoot; + var bucket = GetOption(args, "--surface-bucket") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_BUCKET") + ?? SurfaceCasLayout.DefaultBucket; + var rootPrefix = GetOption(args, "--surface-root-prefix") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ROOT_PREFIX") + ?? SurfaceCasLayout.DefaultRootPrefix; + var tenant = GetOption(args, "--surface-tenant") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_TENANT") + ?? "default"; + var component = GetOption(args, "--surface-component") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT") + ?? "scanner.buildx"; + var componentVersion = GetOption(args, "--surface-component-version") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT_VERSION") + ?? generatorVersion; + var workerInstance = GetOption(args, "--surface-worker-instance") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_WORKER_INSTANCE") + ?? Environment.MachineName; + var attemptValue = GetOption(args, "--surface-attempt") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ATTEMPT"); + var attempt = 1; + if (!string.IsNullOrWhiteSpace(attemptValue) && int.TryParse(attemptValue, out var parsedAttempt) && parsedAttempt > 0) + { + attempt = parsedAttempt; + } + + var scanId = GetOption(args, "--surface-scan-id") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_SCAN_ID") + ?? descriptorRequest.SbomName + ?? descriptorRequest.ImageDigest; + + var manifestOutput = GetOption(args, "--surface-manifest-output") + ?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_MANIFEST_OUTPUT"); + + return new SurfaceOptions( + CacheRoot: cacheRoot, + CacheBucket: bucket, + RootPrefix: rootPrefix, + Tenant: tenant, + Component: component, + ComponentVersion: componentVersion, + WorkerInstance: workerInstance, + Attempt: attempt, + ImageDigest: descriptorRequest.ImageDigest, + ScanId: scanId, + LayerFragmentsPath: layerFragmentsPath, + EntryTraceGraphPath: entryTraceGraphPath, + EntryTraceNdjsonPath: entryTraceNdjsonPath, + ManifestOutputPath: manifestOutput); + } + + private static string? GetOption(string[] args, string optionName) + { + for (var i = 0; i < args.Length; i++) + { + var argument = args[i]; + if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 >= args.Length) + { + throw new BuildxPluginException($"Option '{optionName}' requires a value."); + } + + return args[i + 1]; + } + + if (argument.StartsWith(optionName + "=", StringComparison.OrdinalIgnoreCase)) + { + return argument[(optionName.Length + 1)..]; + } + } + + return null; + } + + private static bool GetFlag(string[] args, string optionName) + { + foreach (var argument in args) + { + if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string RequireOption(string[] args, string optionName) + { + var value = GetOption(args, optionName); + if (string.IsNullOrWhiteSpace(value)) + { + throw new BuildxPluginException($"Option '{optionName}' is required."); + } + + return value; + } + + private static HttpClient CreateAttestorHttpClient(Uri attestorUri, string? bearerToken, bool insecure) + { + var handler = new HttpClientHandler + { + CheckCertificateRevocationList = true, + }; + + if (insecure && string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { +#pragma warning disable S4830 // Explicitly gated by --attestor-insecure flag/env for dev/test usage. + handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; +#pragma warning restore S4830 + } + + var client = new HttpClient(handler, disposeHandler: true) + { + Timeout = TimeSpan.FromSeconds(30) + }; + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + + return client; + } +} diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj index ae1a50c9a..1cff53a7f 100644 --- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj +++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj @@ -1,17 +1,17 @@ - - - net10.0 - enable - enable - Exe - StellaOps.Scanner.Sbomer.BuildXPlugin - StellaOps.Scanner.Sbomer.BuildXPlugin - 0.1.0-alpha - 0.1.0.0 - 0.1.0.0 - 0.1.0-alpha - - + + + net10.0 + enable + enable + Exe + StellaOps.Scanner.Sbomer.BuildXPlugin + StellaOps.Scanner.Sbomer.BuildXPlugin + 0.1.0-alpha + 0.1.0.0 + 0.1.0.0 + 0.1.0-alpha + + PreserveNewest diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceCasLayout.cs b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceCasLayout.cs index c07b22caf..35d271ed3 100644 --- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceCasLayout.cs +++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceCasLayout.cs @@ -1,8 +1,8 @@ using System; using System.IO; -using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; +using StellaOps.Cryptography; namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Surface; @@ -67,11 +67,11 @@ internal static class SurfaceCasLayout return $"cas://{normalizedBucket}/{normalizedKey}"; } - public static string ComputeDigest(ReadOnlySpan content) + public static string ComputeDigest(ICryptoHash hash, ReadOnlySpan content, string algorithmId = HashAlgorithms.Sha256) { - Span hash = stackalloc byte[32]; - SHA256.HashData(content, hash); - return $"{Sha256}:{Convert.ToHexString(hash).ToLowerInvariant()}"; + var hex = hash.ComputeHashHex(content, algorithmId); + var prefix = algorithmId.Equals(HashAlgorithms.Sha256, StringComparison.OrdinalIgnoreCase) ? Sha256 : algorithmId.ToLowerInvariant(); + return prefix + ":" + hex; } public static async Task WriteBytesAsync(string rootDirectory, string objectKey, byte[] bytes, CancellationToken cancellationToken) diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceManifestWriter.cs b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceManifestWriter.cs index c6d09dd12..082d5d206 100644 --- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceManifestWriter.cs +++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/Surface/SurfaceManifestWriter.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using StellaOps.Cryptography; using StellaOps.Scanner.Surface.FS; namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Surface; @@ -19,10 +20,12 @@ internal sealed class SurfaceManifestWriter }; private readonly TimeProvider _timeProvider; + private readonly ICryptoHash _hash; - public SurfaceManifestWriter(TimeProvider timeProvider) + public SurfaceManifestWriter(TimeProvider timeProvider, ICryptoHash hash) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); } public async Task WriteAsync(SurfaceOptions options, CancellationToken cancellationToken) @@ -71,7 +74,7 @@ internal sealed class SurfaceManifestWriter View: null, CasKind: SurfaceCasKind.EntryTraceGraph, FilePath: EnsurePath(options.EntryTraceGraphPath!, "EntryTrace graph path is required.")); - artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, cancellationToken).ConfigureAwait(false)); + artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, _hash, cancellationToken).ConfigureAwait(false)); } if (!string.IsNullOrWhiteSpace(options.EntryTraceNdjsonPath)) @@ -83,7 +86,7 @@ internal sealed class SurfaceManifestWriter View: null, CasKind: SurfaceCasKind.EntryTraceNdjson, FilePath: EnsurePath(options.EntryTraceNdjsonPath!, "EntryTrace NDJSON path is required.")); - artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, cancellationToken).ConfigureAwait(false)); + artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, _hash, cancellationToken).ConfigureAwait(false)); } if (!string.IsNullOrWhiteSpace(options.LayerFragmentsPath)) @@ -95,7 +98,7 @@ internal sealed class SurfaceManifestWriter View: "inventory", CasKind: SurfaceCasKind.LayerFragments, FilePath: EnsurePath(options.LayerFragmentsPath!, "Layer fragments path is required.")); - artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, cancellationToken).ConfigureAwait(false)); + artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, _hash, cancellationToken).ConfigureAwait(false)); } if (artifacts.Count == 0) @@ -127,7 +130,7 @@ internal sealed class SurfaceManifestWriter }; var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, ManifestSerializerOptions); - var manifestDigest = SurfaceCasLayout.ComputeDigest(manifestBytes); + var manifestDigest = SurfaceCasLayout.ComputeDigest(_hash, manifestBytes); var manifestKey = SurfaceCasLayout.BuildObjectKey(rootPrefix, SurfaceCasKind.Manifest, manifestDigest); var manifestPath = await SurfaceCasLayout.WriteBytesAsync(cacheRoot, manifestKey, manifestBytes, cancellationToken).ConfigureAwait(false); var manifestUri = SurfaceCasLayout.BuildCasUri(bucket, manifestKey); @@ -157,6 +160,7 @@ internal sealed class SurfaceManifestWriter string cacheRoot, string bucket, string rootPrefix, + ICryptoHash hash, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -167,7 +171,7 @@ internal sealed class SurfaceManifestWriter } var content = await File.ReadAllBytesAsync(descriptor.FilePath, cancellationToken).ConfigureAwait(false); - var digest = SurfaceCasLayout.ComputeDigest(content); + var digest = SurfaceCasLayout.ComputeDigest(hash, content); var objectKey = SurfaceCasLayout.BuildObjectKey(rootPrefix, descriptor.CasKind, digest); var filePath = await SurfaceCasLayout.WriteBytesAsync(cacheRoot, objectKey, content, cancellationToken).ConfigureAwait(false); var uri = SurfaceCasLayout.BuildCasUri(bucket, objectKey); diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs index cb526b458..faa165010 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using StellaOps.Configuration; using StellaOps.Scanner.Storage; namespace StellaOps.Scanner.WebService.Options; @@ -76,6 +77,11 @@ public sealed class ScannerWebServiceOptions /// public EventsOptions Events { get; set; } = new(); + /// + /// Sovereign cryptography configuration for this host. + /// + public StellaOpsCryptoOptions Crypto { get; set; } = new(); + /// /// Runtime ingestion configuration. /// diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index c9a95c6b3..3de170deb 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -48,13 +48,15 @@ builder.Configuration.AddStellaOpsDefaults(options => var contentRoot = builder.Environment.ContentRootPath; -var bootstrapOptions = builder.Configuration.BindOptions( - ScannerWebServiceOptions.SectionName, - (opts, _) => - { - ScannerWebServiceOptionsPostConfigure.Apply(opts, contentRoot); - ScannerWebServiceOptionsValidator.Validate(opts); - }); +var bootstrapOptions = builder.Configuration.BindOptions( + ScannerWebServiceOptions.SectionName, + (opts, _) => + { + ScannerWebServiceOptionsPostConfigure.Apply(opts, contentRoot); + ScannerWebServiceOptionsValidator.Validate(opts); + }); + +builder.Services.AddStellaOpsCrypto(bootstrapOptions.Crypto); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(ScannerWebServiceOptions.SectionName)) diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/SurfacePointerService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/SurfacePointerService.cs index 24dd9a6a1..f00045e2b 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/SurfacePointerService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/SurfacePointerService.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Cryptography; using StellaOps.Scanner.Storage; using StellaOps.Scanner.Storage.Catalog; using StellaOps.Scanner.Storage.ObjectStore; @@ -36,6 +37,7 @@ internal sealed class SurfacePointerService : ISurfacePointerService private readonly ISurfaceEnvironment _surfaceEnvironment; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; + private readonly ICryptoHash _hash; public SurfacePointerService( LinkRepository linkRepository, @@ -43,7 +45,8 @@ internal sealed class SurfacePointerService : ISurfacePointerService IOptionsMonitor optionsMonitor, ISurfaceEnvironment surfaceEnvironment, TimeProvider timeProvider, - ILogger logger) + ILogger logger, + ICryptoHash hash) { _linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository)); _artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository)); @@ -51,6 +54,7 @@ internal sealed class SurfacePointerService : ISurfacePointerService _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); } public async Task TryBuildAsync(string imageDigest, CancellationToken cancellationToken) @@ -275,15 +279,9 @@ internal sealed class SurfacePointerService : ISurfacePointerService ? string.Empty : value.Trim().TrimEnd('/'); - private static string ComputeDigest(ReadOnlySpan payload) + private string ComputeDigest(ReadOnlySpan payload) { - Span hash = stackalloc byte[32]; - if (!SHA256.TryHashData(payload, hash, out _)) - { - using var sha = SHA256.Create(); - hash = sha.ComputeHash(payload.ToArray()); - } - - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + var hex = _hash.ComputeHashHex(payload, HashAlgorithms.Sha256); + return $"sha256:{hex}"; } } diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md index d9c63f8e3..810694416 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md @@ -4,6 +4,7 @@ |----|--------|----------|------------|-------------|---------------| | SCAN-REPLAY-186-001 | TODO | Scanner WebService Guild | REPLAY-CORE-185-001 | Implement scan `record` mode producing replay manifests/bundles, capture policy/feed/tool hashes, and update `docs/modules/scanner/architecture.md` referencing `docs/replay/DETERMINISTIC_REPLAY.md` Section 6. | API/worker integration tests cover record mode; docs merged; replay artifacts stored per spec. | | SCANNER-SURFACE-02 | DONE (2025-11-05) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.
2025-11-05: Surface pointers projected through scan/report endpoints, orchestrator samples + DSSE fixtures refreshed with manifest block, readiness tests updated to use validator stub. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. | +| SCANNER-CRYPTO-90-001 | TODO | Scanner WebService Guild, Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Route hashing/signing flows (`ScanIdGenerator`, `ReportSigner`, Sbomer Buildx plugin) through `ICryptoProviderRegistry` so sovereign deployments can select `ru.cryptopro.csp` / `ru.pkcs11` providers. Reference `docs/security/crypto-routing-audit-2025-11-07.md`. | Config toggles verified for default + RU bundles; report/scan APIs emit signatures via registry-backed providers; regression tests updated. | | SCANNER-ENV-02 | TODO (2025-11-06) | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration.
2025-11-02: Cache root resolution switched to helper; feature flag bindings updated; Helm/Compose updates pending review.
2025-11-05 14:55Z: Aligning readiness checks, docs, and Helm/Compose templates with Surface.Env outputs and planning test coverage for configuration fallbacks.
2025-11-06 17:05Z: Surface.Env documentation/README refreshed; warning catalogue captured for ops handoff.
2025-11-06 07:45Z: Helm values (dev/stage/prod/airgap/mirror) and Compose examples updated with `SCANNER_SURFACE_*` defaults plus rollout warning note in `deploy/README.md`.
2025-11-06 07:55Z: Paused; follow-up automation captured under `DEVOPS-OPENSSL-11-001/002` and pending Surface.Env readiness tests. | Service uses helper; env table documented; helm/compose templates updated. | > 2025-11-05 19:18Z: Added configurator to project wiring and unit test ensuring Surface.Env cache root is honoured. | SCANNER-SECRETS-02 | DONE (2025-11-06) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).
2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress.
2025-11-06: Restarting work to eliminate file-based secrets, plumb provider handles through report/export services, and extend failure/rotation tests.
2025-11-06 21:40Z: Added configurator + storage post-config to hydrate artifact/CAS credentials from `cas-access` secrets with unit coverage.
2025-11-06 23:58Z: Registry & attestation secrets now resolved via Surface.Secrets (options + tests updated); dotnet test suites executed with .NET 10 RC2 runtime where available. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. | diff --git a/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerWorkerOptions.cs b/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerWorkerOptions.cs index 32bbe9f05..93c5aea9f 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerWorkerOptions.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerWorkerOptions.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using StellaOps.Configuration; using StellaOps.Scanner.Core.Contracts; namespace StellaOps.Scanner.Worker.Options; @@ -17,13 +18,15 @@ public sealed class ScannerWorkerOptions public PollingOptions Polling { get; } = new(); - public AuthorityOptions Authority { get; } = new(); - + public AuthorityOptions Authority { get; } = new(); + public TelemetryOptions Telemetry { get; } = new(); public ShutdownOptions Shutdown { get; } = new(); public AnalyzerOptions Analyzers { get; } = new(); + + public StellaOpsCryptoOptions Crypto { get; } = new(); public sealed class QueueOptions { diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs index dab083a25..9d24bb62a 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs @@ -1,15 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Collections.ObjectModel; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.Linq; using System.IO; -using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using StellaOps.Scanner.Analyzers.Lang; +using StellaOps.Scanner.Analyzers.Lang; using StellaOps.Scanner.Analyzers.Lang.Internal; using StellaOps.Scanner.Analyzers.Lang.Plugin; using StellaOps.Scanner.Analyzers.OS; @@ -22,9 +21,9 @@ using StellaOps.Scanner.Surface.FS; using StellaOps.Scanner.Surface.Validation; using StellaOps.Scanner.Worker.Options; using StellaOps.Scanner.Worker.Diagnostics; - -namespace StellaOps.Scanner.Worker.Processing; - + +namespace StellaOps.Scanner.Worker.Processing; + internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher { private readonly IServiceScopeFactory _scopeFactory; @@ -32,17 +31,19 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher private readonly ILanguageAnalyzerPluginCatalog _languageCatalog; private readonly ScannerWorkerOptions _options; private readonly ILogger _logger; + private readonly ICryptoHash _hash; private readonly ScannerWorkerMetrics _metrics; - private IReadOnlyList _osPluginDirectories = Array.Empty(); - private IReadOnlyList _languagePluginDirectories = Array.Empty(); - - public CompositeScanAnalyzerDispatcher( - IServiceScopeFactory scopeFactory, - IOSAnalyzerPluginCatalog osCatalog, + private IReadOnlyList _osPluginDirectories = Array.Empty(); + private IReadOnlyList _languagePluginDirectories = Array.Empty(); + + public CompositeScanAnalyzerDispatcher( + IServiceScopeFactory scopeFactory, + IOSAnalyzerPluginCatalog osCatalog, ILanguageAnalyzerPluginCatalog languageCatalog, IOptions options, ILogger logger, - ScannerWorkerMetrics metrics) + ScannerWorkerMetrics metrics, + ICryptoHash hash) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _osCatalog = osCatalog ?? throw new ArgumentNullException(nameof(osCatalog)); @@ -50,97 +51,98 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); - - LoadPlugins(); - } - - public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - - using var scope = _scopeFactory.CreateScope(); - var services = scope.ServiceProvider; - - var osAnalyzers = _osCatalog.CreateAnalyzers(services); - var languageAnalyzers = _languageCatalog.CreateAnalyzers(services); - - if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0) - { - _logger.LogWarning("No analyzer plug-ins available; skipping analyzer stage for job {JobId}.", context.JobId); - return; - } - - var metadata = new Dictionary(context.Lease.Metadata, StringComparer.Ordinal); - var rootfsPath = ResolvePath(metadata, _options.Analyzers.RootFilesystemMetadataKey); - var workspacePath = ResolvePath(metadata, _options.Analyzers.WorkspaceMetadataKey) ?? rootfsPath; - - if (osAnalyzers.Count > 0) - { - await ExecuteOsAnalyzersAsync(context, osAnalyzers, services, rootfsPath, workspacePath, cancellationToken) - .ConfigureAwait(false); - } - - if (languageAnalyzers.Count > 0) - { - await ExecuteLanguageAnalyzersAsync(context, languageAnalyzers, services, workspacePath, cancellationToken) - .ConfigureAwait(false); - } - } - - private async Task ExecuteOsAnalyzersAsync( - ScanJobContext context, - IReadOnlyList analyzers, - IServiceProvider services, - string? rootfsPath, - string? workspacePath, - CancellationToken cancellationToken) - { - if (rootfsPath is null) - { - _logger.LogWarning( - "Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. OS analyzers skipped.", - _options.Analyzers.RootFilesystemMetadataKey, - context.JobId); - return; - } - - var loggerFactory = services.GetRequiredService(); - var results = new List(analyzers.Count); - - foreach (var analyzer in analyzers) - { - cancellationToken.ThrowIfCancellationRequested(); - - var analyzerLogger = loggerFactory.CreateLogger(analyzer.GetType()); - var analyzerContext = new OSPackageAnalyzerContext(rootfsPath, workspacePath, context.TimeProvider, analyzerLogger, context.Lease.Metadata); - - try - { - var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false); - results.Add(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Analyzer {AnalyzerId} failed for job {JobId}.", analyzer.AnalyzerId, context.JobId); - } - } - - if (results.Count == 0) - { - return; - } - - var dictionary = results.ToDictionary(result => result.AnalyzerId, StringComparer.OrdinalIgnoreCase); - context.Analysis.Set(ScanAnalysisKeys.OsPackageAnalyzers, dictionary); - - var fragments = OsComponentMapper.ToLayerFragments(results); - if (!fragments.IsDefaultOrEmpty) - { - context.Analysis.AppendLayerFragments(fragments); - context.Analysis.Set(ScanAnalysisKeys.OsComponentFragments, fragments); - } - } - + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); + + LoadPlugins(); + } + + public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + using var scope = _scopeFactory.CreateScope(); + var services = scope.ServiceProvider; + + var osAnalyzers = _osCatalog.CreateAnalyzers(services); + var languageAnalyzers = _languageCatalog.CreateAnalyzers(services); + + if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0) + { + _logger.LogWarning("No analyzer plug-ins available; skipping analyzer stage for job {JobId}.", context.JobId); + return; + } + + var metadata = new Dictionary(context.Lease.Metadata, StringComparer.Ordinal); + var rootfsPath = ResolvePath(metadata, _options.Analyzers.RootFilesystemMetadataKey); + var workspacePath = ResolvePath(metadata, _options.Analyzers.WorkspaceMetadataKey) ?? rootfsPath; + + if (osAnalyzers.Count > 0) + { + await ExecuteOsAnalyzersAsync(context, osAnalyzers, services, rootfsPath, workspacePath, cancellationToken) + .ConfigureAwait(false); + } + + if (languageAnalyzers.Count > 0) + { + await ExecuteLanguageAnalyzersAsync(context, languageAnalyzers, services, workspacePath, cancellationToken) + .ConfigureAwait(false); + } + } + + private async Task ExecuteOsAnalyzersAsync( + ScanJobContext context, + IReadOnlyList analyzers, + IServiceProvider services, + string? rootfsPath, + string? workspacePath, + CancellationToken cancellationToken) + { + if (rootfsPath is null) + { + _logger.LogWarning( + "Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. OS analyzers skipped.", + _options.Analyzers.RootFilesystemMetadataKey, + context.JobId); + return; + } + + var loggerFactory = services.GetRequiredService(); + var results = new List(analyzers.Count); + + foreach (var analyzer in analyzers) + { + cancellationToken.ThrowIfCancellationRequested(); + + var analyzerLogger = loggerFactory.CreateLogger(analyzer.GetType()); + var analyzerContext = new OSPackageAnalyzerContext(rootfsPath, workspacePath, context.TimeProvider, analyzerLogger, context.Lease.Metadata); + + try + { + var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false); + results.Add(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Analyzer {AnalyzerId} failed for job {JobId}.", analyzer.AnalyzerId, context.JobId); + } + } + + if (results.Count == 0) + { + return; + } + + var dictionary = results.ToDictionary(result => result.AnalyzerId, StringComparer.OrdinalIgnoreCase); + context.Analysis.Set(ScanAnalysisKeys.OsPackageAnalyzers, dictionary); + + var fragments = OsComponentMapper.ToLayerFragments(results); + if (!fragments.IsDefaultOrEmpty) + { + context.Analysis.AppendLayerFragments(fragments); + context.Analysis.Set(ScanAnalysisKeys.OsComponentFragments, fragments); + } + } + private async Task ExecuteLanguageAnalyzersAsync( ScanJobContext context, IReadOnlyList analyzers, @@ -189,7 +191,7 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher context.JobId); var fallbackBytes = Encoding.UTF8.GetBytes(workspacePath); - workspaceFingerprint = Convert.ToHexString(SHA256.HashData(fallbackBytes)).ToLowerInvariant(); + workspaceFingerprint = _hash.ComputeHashHex(fallbackBytes, HashAlgorithms.Sha256); } var cache = services.GetRequiredService(); @@ -261,85 +263,85 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher context.Analysis.Set(ScanAnalysisKeys.LanguageComponentFragments, immutableFragments); } } - - private void LoadPlugins() - { - _osPluginDirectories = NormalizeDirectories(_options.Analyzers.PluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "os")); - for (var i = 0; i < _osPluginDirectories.Count; i++) - { - var directory = _osPluginDirectories[i]; - var seal = i == _osPluginDirectories.Count - 1; - - try - { - _osCatalog.LoadFromDirectory(directory, seal); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load OS analyzer plug-ins from {Directory}.", directory); - } - } - - _languagePluginDirectories = NormalizeDirectories(_options.Analyzers.LanguagePluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "lang")); - for (var i = 0; i < _languagePluginDirectories.Count; i++) - { - var directory = _languagePluginDirectories[i]; - var seal = i == _languagePluginDirectories.Count - 1; - - try - { - _languageCatalog.LoadFromDirectory(directory, seal); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load language analyzer plug-ins from {Directory}.", directory); - } - } - } - - private static IReadOnlyList NormalizeDirectories(IEnumerable configured, string fallbackRelative) - { - var directories = new List(); - foreach (var configuredPath in configured ?? Array.Empty()) - { - if (string.IsNullOrWhiteSpace(configuredPath)) - { - continue; - } - - var path = configuredPath; - if (!Path.IsPathRooted(path)) - { - path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path)); - } - - directories.Add(path); - } - - if (directories.Count == 0) - { - var fallback = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, fallbackRelative)); - directories.Add(fallback); - } - - return new ReadOnlyCollection(directories); - } - - private static string? ResolvePath(IReadOnlyDictionary metadata, string key) - { - if (string.IsNullOrWhiteSpace(key)) - { - return null; - } - - if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) - { - return null; - } - - var trimmed = value.Trim(); - return Path.IsPathRooted(trimmed) - ? trimmed - : Path.GetFullPath(trimmed); - } -} + + private void LoadPlugins() + { + _osPluginDirectories = NormalizeDirectories(_options.Analyzers.PluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "os")); + for (var i = 0; i < _osPluginDirectories.Count; i++) + { + var directory = _osPluginDirectories[i]; + var seal = i == _osPluginDirectories.Count - 1; + + try + { + _osCatalog.LoadFromDirectory(directory, seal); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load OS analyzer plug-ins from {Directory}.", directory); + } + } + + _languagePluginDirectories = NormalizeDirectories(_options.Analyzers.LanguagePluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "lang")); + for (var i = 0; i < _languagePluginDirectories.Count; i++) + { + var directory = _languagePluginDirectories[i]; + var seal = i == _languagePluginDirectories.Count - 1; + + try + { + _languageCatalog.LoadFromDirectory(directory, seal); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load language analyzer plug-ins from {Directory}.", directory); + } + } + } + + private static IReadOnlyList NormalizeDirectories(IEnumerable configured, string fallbackRelative) + { + var directories = new List(); + foreach (var configuredPath in configured ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + { + continue; + } + + var path = configuredPath; + if (!Path.IsPathRooted(path)) + { + path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path)); + } + + directories.Add(path); + } + + if (directories.Count == 0) + { + var fallback = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, fallbackRelative)); + directories.Add(fallback); + } + + return new ReadOnlyCollection(directories); + } + + private static string? ResolvePath(IReadOnlyDictionary metadata, string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return null; + } + + if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return Path.IsPathRooted(trimmed) + ? trimmed + : Path.GetFullPath(trimmed); + } +} diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/EntryTraceExecutionService.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/EntryTraceExecutionService.cs index d75ffb5c5..2b201fdf4 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Processing/EntryTraceExecutionService.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/EntryTraceExecutionService.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -56,6 +55,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService private readonly ISurfaceCache _surfaceCache; private readonly ISurfaceSecretProvider _surfaceSecrets; private readonly IServiceProvider _serviceProvider; + private readonly ICryptoHash _hash; public EntryTraceExecutionService( IEntryTraceAnalyzer analyzer, @@ -69,7 +69,8 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService ISurfaceEnvironment surfaceEnvironment, ISurfaceCache surfaceCache, ISurfaceSecretProvider surfaceSecrets, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + ICryptoHash hash) { _analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer)); _entryTraceOptions = (entryTraceOptions ?? throw new ArgumentNullException(nameof(entryTraceOptions))).Value ?? new EntryTraceAnalyzerOptions(); @@ -83,6 +84,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService _surfaceCache = surfaceCache ?? throw new ArgumentNullException(nameof(surfaceCache)); _surfaceSecrets = surfaceSecrets ?? throw new ArgumentNullException(nameof(surfaceSecrets)); _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); } public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken) @@ -376,7 +378,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService return true; } - private static SurfaceCacheKey CreateCacheKey( + private SurfaceCacheKey CreateCacheKey( string imageDigest, EntryTraceImageContext context, string tenant, @@ -390,11 +392,11 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService builder.Append('|').Append(ComputeEnvironmentFingerprint(context.Context.Environment)); builder.Append('|').Append(optionsFingerprint); - var hash = ComputeSha256(builder.ToString()); - return new SurfaceCacheKey(CacheNamespace, tenant, hash); + var fingerprint = ComputeSha256(builder.ToString()); + return new SurfaceCacheKey(CacheNamespace, tenant, fingerprint); } - private static string ComputeOptionsFingerprint(EntryTraceAnalyzerOptions options) + private string ComputeOptionsFingerprint(EntryTraceAnalyzerOptions options) { var builder = new StringBuilder(); builder.Append(options.MaxDepth); @@ -404,7 +406,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService return ComputeSha256(builder.ToString()); } - private static string ComputeEntrypointSignature(EntrypointSpecification specification) + private string ComputeEntrypointSignature(EntrypointSpecification specification) { var builder = new StringBuilder(); builder.AppendJoin(',', specification.Entrypoint); @@ -415,7 +417,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService return ComputeSha256(builder.ToString()); } - private static string ComputeEnvironmentFingerprint(ImmutableDictionary environment) + private string ComputeEnvironmentFingerprint(ImmutableDictionary environment) { if (environment.Count == 0) { @@ -431,12 +433,10 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService return ComputeSha256(builder.ToString()); } - private static string ComputeSha256(string value) + private string ComputeSha256(string value) { - using var sha = SHA256.Create(); var bytes = Encoding.UTF8.GetBytes(value); - var hash = sha.ComputeHash(bytes); - return Convert.ToHexString(hash).ToLowerInvariant(); + return _hash.ComputeHashHex(bytes, HashAlgorithms.Sha256); } private static string? ResolvePath( diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs index bfcb4a5ba..c3acd6aee 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -15,6 +14,7 @@ using StellaOps.Scanner.Storage.ObjectStore; using StellaOps.Scanner.Storage.Repositories; using StellaOps.Scanner.Storage.Services; using StellaOps.Scanner.Surface.Env; +using StellaOps.Cryptography; namespace StellaOps.Scanner.Worker.Processing.Surface; @@ -58,6 +58,7 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher private readonly ISurfaceEnvironment _surfaceEnvironment; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; + private readonly ICryptoHash _hash; public SurfaceManifestPublisher( IArtifactObjectStore objectStore, @@ -66,7 +67,8 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher IOptions storageOptions, ISurfaceEnvironment surfaceEnvironment, TimeProvider timeProvider, - ILogger logger) + ILogger logger, + ICryptoHash hash) { _objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore)); _artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository)); @@ -75,6 +77,7 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); } public async Task PublishAsync(SurfaceManifestRequest request, CancellationToken cancellationToken) @@ -245,14 +248,13 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher _ => format.ToString().ToLowerInvariant() }; - private static string ComputeDigest(ReadOnlySpan content) + private string ComputeDigest(ReadOnlySpan content) { - Span hash = stackalloc byte[32]; - SHA256.HashData(content, hash); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + var hex = _hash.ComputeHashHex(content, HashAlgorithms.Sha256); + return $"sha256:{hex}"; } - private static string ComputeDigest(byte[] content) + private string ComputeDigest(byte[] content) => ComputeDigest(content.AsSpan()); private static string NormalizeDigest(string digest) diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs index 1fda222d9..0537dace8 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs @@ -3,7 +3,6 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; using System.Reflection; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -15,6 +14,7 @@ using StellaOps.Scanner.Surface.Env; using StellaOps.Scanner.Surface.FS; using StellaOps.Scanner.Storage.Catalog; using StellaOps.Scanner.Worker.Diagnostics; +using StellaOps.Cryptography; namespace StellaOps.Scanner.Worker.Processing.Surface; @@ -37,6 +37,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor private readonly ISurfaceEnvironment _surfaceEnvironment; private readonly ScannerWorkerMetrics _metrics; private readonly ILogger _logger; + private readonly ICryptoHash _hash; private readonly string _componentVersion; public SurfaceManifestStageExecutor( @@ -44,13 +45,15 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor ISurfaceCache surfaceCache, ISurfaceEnvironment surfaceEnvironment, ScannerWorkerMetrics metrics, - ILogger logger) + ILogger logger, + ICryptoHash hash) { _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); _surfaceCache = surfaceCache ?? throw new ArgumentNullException(nameof(surfaceCache)); _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment)); _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); _componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; } @@ -274,11 +277,10 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor return digest.Trim(); } - private static string ComputeDigest(ReadOnlySpan content) + private string ComputeDigest(ReadOnlySpan content) { - Span hash = stackalloc byte[32]; - SHA256.HashData(content, hash); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + var hex = _hash.ComputeHashHex(content, HashAlgorithms.Sha256); + return $"sha256:{hex}"; } private static readonly IFormatProvider CultureInfoInvariant = System.Globalization.CultureInfo.InvariantCulture; diff --git a/src/Scanner/StellaOps.Scanner.Worker/Program.cs b/src/Scanner/StellaOps.Scanner.Worker/Program.cs index a33486250..efe865b7c 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Program.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.DependencyInjection.Extensions; using StellaOps.Auth.Client; +using StellaOps.Configuration; using StellaOps.Scanner.Cache; using StellaOps.Scanner.Analyzers.OS.Plugin; using StellaOps.Scanner.Analyzers.Lang.Plugin; @@ -71,6 +72,7 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); var workerOptions = builder.Configuration.GetSection(ScannerWorkerOptions.SectionName).Get() ?? new ScannerWorkerOptions(); +builder.Services.AddStellaOpsCrypto(workerOptions.Crypto); builder.Services.Configure(options => { diff --git a/src/Scanner/StellaOps.Scanner.Worker/TASKS.md b/src/Scanner/StellaOps.Scanner.Worker/TASKS.md index 484ce5ad1..48e26ed74 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.Worker/TASKS.md @@ -3,6 +3,7 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| | SCAN-REPLAY-186-002 | TODO | Scanner Worker Guild | REPLAY-CORE-185-001 | Enforce deterministic analyzer execution when consuming replay input bundles, emit layer Merkle metadata, and author `docs/modules/scanner/deterministic-execution.md` summarising invariants from `docs/replay/DETERMINISTIC_REPLAY.md` Section 4. | Replay mode analyzers pass determinism tests; new doc merged; integration fixtures updated. | +| SCANNER-CRYPTO-90-001 | DONE (2025-11-08) | Scanner Worker Guild & Security Guild | SEC-CRYPTO-90-005 | Route remaining hashing and digest consumers (Surface pointers, manifest publishers, CAS helpers, Sbomer plugins) through ICryptoHash and the configured provider registry.
2025-11-08: Worker EntryTrace service, CAS helpers, and Sbomer plugin now depend on ICryptoHash; Local CAS + manifest writer persisted digests via providers; tests updated with CryptoHashFactory/TestCryptoHash helpers; runtime SHA256 calls removed. | No direct SHA256.Create() usage in worker runtime; constructors accept ICryptoHash; tests updated. | | SCANNER-SURFACE-01 | DONE (2025-11-06) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review.
2025-11-06: Resuming with manifest writer abstraction, rotation metadata, and telemetry counters for Surface.FS persistence.
2025-11-06 21:05Z: Stage now persists manifest/payload caches, exports metrics to Prometheus/Grafana, and WebService pointer tests validate consumption. | Integration tests prove cache entries exist; telemetry counters exported. | > 2025-11-05 19:18Z: Bound root directory to resolved Surface.Env settings and added unit coverage around the configurator. > 2025-11-06 18:45Z: Resuming manifest persistence—planning publisher abstraction refactor, CAS storage wiring, and telemetry/test coverage. @@ -10,3 +11,4 @@ > 2025-11-06 21:05Z: Completed Surface manifest cache + metrics work; tests/docs updated and task ready to close. | SCANNER-ENV-01 | TODO (2025-11-06) | Scanner Worker Guild | SURFACE-ENV-02 | Replace ad-hoc environment reads with `StellaOps.Scanner.Surface.Env` helpers for cache roots and CAS endpoints.
2025-11-02: Worker bootstrap now resolves cache roots via helper; warning path documented; smoke tests running.
2025-11-05 14:55Z: Extending helper usage into cache/secrets configuration, updating worker validator wiring, and drafting docs/tests for new Surface.Env outputs.
2025-11-06 17:05Z: README/design docs updated with warning catalogue; startup logging guidance captured for ops runbooks.
2025-11-06 07:45Z: Helm/Compose env profiles (dev/stage/prod/airgap/mirror) now seed `SCANNER_SURFACE_*` defaults to keep worker cache roots aligned with Surface.Env helpers.
2025-11-06 07:55Z: Paused; pending automation tracked via `DEVOPS-OPENSSL-11-001/002` and Surface.Env test fixtures. | Worker boots with helper; misconfiguration warnings documented; smoke tests updated. | | SCANNER-SECRETS-01 | DONE (2025-11-06) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added.
2025-11-06: Replaced registry credential plumbing with shared provider, added registry secret stage + metrics, and installed .NET 10 RC2 to validate parser/stage suites via targeted `dotnet test`. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. | +| SCAN-REACH-201-002 | DOING (2025-11-08) | Scanner Worker Guild | SIGNALS-24-002 | Implement language-aware reachability lifters (JVM/WALA, .NET Roslyn+IL, Go SSA, Node/Deno TS AST, Rust MIR, Swift SIL, shell/binary analyzers) emitting canonical SymbolIDs, CAS-stored callgraphs, and `reachability:*` SBOM tags consumed by Signals + Policy. | Fixture library + unit tests per language; CAS manifests published; SBOM components carry reachability tags; docs updated. | diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityGraphBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityGraphBuilder.cs new file mode 100644 index 000000000..ef18280e8 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityGraphBuilder.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace StellaOps.Scanner.Reachability; + +public sealed class ReachabilityGraphBuilder +{ + private const string GraphSchemaVersion = "1.0"; + private readonly HashSet nodes = new(StringComparer.Ordinal); + private readonly HashSet edges = new(); + + public ReachabilityGraphBuilder AddNode(string symbolId) + { + if (!string.IsNullOrWhiteSpace(symbolId)) + { + nodes.Add(symbolId.Trim()); + } + + return this; + } + + public ReachabilityGraphBuilder AddEdge(string from, string to, string kind = "call") + { + if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to)) + { + return this; + } + + var edge = new ReachabilityEdge(from.Trim(), to.Trim(), string.IsNullOrWhiteSpace(kind) ? "call" : kind.Trim()); + edges.Add(edge); + nodes.Add(edge.From); + nodes.Add(edge.To); + return this; + } + + public string BuildJson(bool indented = true) + { + var payload = new ReachabilityGraphPayload + { + SchemaVersion = GraphSchemaVersion, + Nodes = nodes.Select(id => new ReachabilityNode(id)).ToList(), + Edges = edges.Select(edge => new ReachabilityEdgePayload(edge.From, edge.To, edge.Kind)).ToList() + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = indented + }; + + return JsonSerializer.Serialize(payload, options); + } + + public static ReachabilityGraphBuilder FromFixture(string variantPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(variantPath); + var builder = new ReachabilityGraphBuilder(); + + foreach (var fileName in new[] { "callgraph.static.json", "callgraph.framework.json" }) + { + var path = Path.Combine(variantPath, fileName); + if (!File.Exists(path)) + { + continue; + } + + using var stream = File.OpenRead(path); + using var document = JsonDocument.Parse(stream); + var root = document.RootElement; + + if (root.TryGetProperty("nodes", out var nodesElement) && nodesElement.ValueKind == JsonValueKind.Array) + { + foreach (var node in nodesElement.EnumerateArray()) + { + var sid = node.TryGetProperty("sid", out var sidElement) + ? sidElement.GetString() + : node.GetProperty("id").GetString(); + builder.AddNode(sid ?? string.Empty); + } + } + + if (root.TryGetProperty("edges", out var edgesElement) && edgesElement.ValueKind == JsonValueKind.Array) + { + foreach (var edge in edgesElement.EnumerateArray()) + { + var from = edge.TryGetProperty("from", out var fromEl) + ? fromEl.GetString() + : edge.GetProperty("source").GetString(); + var to = edge.TryGetProperty("to", out var toEl) + ? toEl.GetString() + : edge.GetProperty("target").GetString(); + var kind = edge.TryGetProperty("kind", out var kindEl) + ? kindEl.GetString() + : edge.TryGetProperty("type", out var typeEl) + ? typeEl.GetString() + : "call"; + + builder.AddEdge(from ?? string.Empty, to ?? string.Empty, kind ?? "call"); + } + } + } + + return builder; + } + + private sealed record ReachabilityEdge(string From, string To, string Kind); + + private sealed record ReachabilityNode(string Sid); + + private sealed record ReachabilityEdgePayload(string From, string To, string Kind); + + private sealed record ReachabilityGraphPayload + { + public string SchemaVersion { get; set; } = GraphSchemaVersion; + public List Nodes { get; set; } = new(); + public List Edges { get; set; } = new(); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityReplayWriter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityReplayWriter.cs new file mode 100644 index 000000000..e234e9ea0 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityReplayWriter.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StellaOps.Replay.Core; + +namespace StellaOps.Scanner.Reachability; + +/// +/// Helper that projects reachability artifacts into the replay manifest. +/// +public sealed class ReachabilityReplayWriter +{ + /// + /// Attaches reachability graphs and runtime traces to the supplied replay manifest. + /// + public void AttachEvidence( + ReplayManifest manifest, + IEnumerable? graphs, + IEnumerable? traces) + { + ArgumentNullException.ThrowIfNull(manifest); + + WriteGraphs(manifest, graphs); + WriteTraces(manifest, traces); + } + + private static void WriteGraphs(ReplayManifest manifest, IEnumerable? graphs) + { + if (graphs is null) + { + return; + } + + var sanitized = graphs + .Where(graph => graph is not null) + .Select(graph => NormalizeGraph(graph!)) + .Where(graph => graph is not null) + .Select(graph => graph!) + .DistinctBy(graph => (graph.Kind, graph.CasUri, graph.Sha256, graph.Analyzer, graph.Version)) + .OrderBy(graph => graph.CasUri, StringComparer.Ordinal) + .ThenBy(graph => graph.Kind, StringComparer.Ordinal) + .ToList(); + + foreach (var graph in sanitized) + { + manifest.AddReachabilityGraph(new ReplayReachabilityGraphReference + { + Kind = graph.Kind, + CasUri = graph.CasUri, + Sha256 = graph.Sha256, + Analyzer = graph.Analyzer, + Version = graph.Version + }); + } + } + + private static void WriteTraces(ReplayManifest manifest, IEnumerable? traces) + { + if (traces is null) + { + return; + } + + var normalized = traces + .Where(trace => trace is not null) + .Select(trace => NormalizeTrace(trace!)) + .Where(trace => trace is not null) + .Select(trace => trace!) + .ToList(); + var collapsed = normalized + .GroupBy(trace => (trace.Source, trace.CasUri, trace.Sha256)) + .Select(group => group.OrderBy(t => t.RecordedAt).First()) + .OrderBy(trace => trace.RecordedAt) + .ThenBy(trace => trace.CasUri, StringComparer.Ordinal) + .ToList(); + + foreach (var trace in collapsed) + { + manifest.AddReachabilityTrace(new ReplayReachabilityTraceReference + { + Source = trace.Source, + CasUri = trace.CasUri, + Sha256 = trace.Sha256, + RecordedAt = trace.RecordedAt + }); + } + } + + private static NormalizedGraph? NormalizeGraph(ReachabilityReplayGraph graph) + { + var casUri = Normalize(graph.CasUri); + if (string.IsNullOrEmpty(casUri)) + { + return null; + } + + return new NormalizedGraph( + Kind: Normalize(graph.Kind) ?? "static", + CasUri: casUri, + Sha256: NormalizeHash(graph.Sha256), + Analyzer: Normalize(graph.Analyzer) ?? string.Empty, + Version: Normalize(graph.Version) ?? string.Empty); + } + + private static NormalizedTrace? NormalizeTrace(ReachabilityReplayTrace trace) + { + var casUri = Normalize(trace.CasUri); + if (string.IsNullOrEmpty(casUri)) + { + return null; + } + + return new NormalizedTrace( + Source: Normalize(trace.Source) ?? string.Empty, + CasUri: casUri, + Sha256: NormalizeHash(trace.Sha256), + RecordedAt: trace.RecordedAt.ToUniversalTime()); + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string NormalizeHash(string? hash) + => string.IsNullOrWhiteSpace(hash) ? string.Empty : hash.Trim().ToLowerInvariant(); + + private sealed record NormalizedGraph( + string Kind, + string CasUri, + string Sha256, + string Analyzer, + string Version); + + private sealed record NormalizedTrace( + string Source, + string CasUri, + string Sha256, + DateTimeOffset RecordedAt); +} + +/// +/// Describes a CAS-backed reachability graph emitted by Scanner. +/// +public sealed record ReachabilityReplayGraph( + string? Kind, + string? CasUri, + string? Sha256, + string? Analyzer, + string? Version); + +/// +/// Describes a runtime trace artifact emitted by Scanner/Zastava. +/// +public sealed record ReachabilityReplayTrace( + string? Source, + string? CasUri, + string? Sha256, + DateTimeOffset RecordedAt); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj new file mode 100644 index 000000000..47c129a73 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/FileSurfaceManifestStore.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/FileSurfaceManifestStore.cs index 9363f6456..3a01cb929 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/FileSurfaceManifestStore.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/FileSurfaceManifestStore.cs @@ -3,11 +3,11 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; -using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Cryptography; namespace StellaOps.Scanner.Surface.FS; @@ -20,12 +20,14 @@ public sealed class FileSurfaceManifestStore : { private readonly ILogger _logger; private readonly SurfaceManifestPathBuilder _pathBuilder; + private readonly ICryptoHash _hash; private readonly SemaphoreSlim _publishGate = new(1, 1); public FileSurfaceManifestStore( IOptions cacheOptions, IOptions storeOptions, - ILogger logger) + ILogger logger, + ICryptoHash hash) { if (cacheOptions is null) { @@ -38,6 +40,7 @@ public sealed class FileSurfaceManifestStore : } _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _hash = hash ?? throw new ArgumentNullException(nameof(hash)); _pathBuilder = new SurfaceManifestPathBuilder(cacheOptions.Value, storeOptions.Value); } @@ -183,11 +186,10 @@ public sealed class FileSurfaceManifestStore : }; } - private static string ComputeDigest(ReadOnlySpan bytes) + private string ComputeDigest(ReadOnlySpan bytes) { - using var sha = SHA256.Create(); - var hash = sha.ComputeHash(bytes); - return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + var digest = _hash.ComputeHash(bytes, HashAlgorithms.Sha256); + return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}"; } private static SurfaceManifestArtifact NormalizeArtifact(SurfaceManifestArtifact artifact) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj index 702f7b478..0cdddc06d 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj @@ -16,12 +16,15 @@
- - - - - - + + + + + + + + +
diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Cas/LocalCasClientTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Cas/LocalCasClientTests.cs index 3e0f541b4..623c74421 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Cas/LocalCasClientTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Cas/LocalCasClientTests.cs @@ -1,34 +1,35 @@ -using System.IO; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; -using Xunit; - -namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Cas; - -public sealed class LocalCasClientTests -{ - [Fact] - public async Task VerifyWriteAsync_WritesProbeObject() - { - await using var temp = new TempDirectory(); - var client = new LocalCasClient(new LocalCasOptions - { - RootDirectory = temp.Path, - Algorithm = "sha256" - }); - - var result = await client.VerifyWriteAsync(CancellationToken.None); - - Assert.Equal("sha256", result.Algorithm); - Assert.True(File.Exists(result.Path)); - - var bytes = await File.ReadAllBytesAsync(result.Path); - Assert.Equal("stellaops-buildx-probe"u8.ToArray(), bytes); - - var expectedDigest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); - Assert.Equal(expectedDigest, result.Digest); - } -} +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Cryptography; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; +using Xunit; + +namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Cas; + +public sealed class LocalCasClientTests +{ + [Fact] + public async Task VerifyWriteAsync_WritesProbeObject() + { + await using var temp = new TempDirectory(); + var client = new LocalCasClient(new LocalCasOptions + { + RootDirectory = temp.Path, + Algorithm = "sha256" + }, CryptoHashFactory.CreateDefault()); + + var result = await client.VerifyWriteAsync(CancellationToken.None); + + Assert.Equal("sha256", result.Algorithm); + Assert.True(File.Exists(result.Path)); + + var bytes = await File.ReadAllBytesAsync(result.Path); + Assert.Equal("stellaops-buildx-probe"u8.ToArray(), bytes); + + var expectedDigest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + Assert.Equal(expectedDigest, result.Digest); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorCommandSurfaceTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorCommandSurfaceTests.cs index 94302a136..2bdc42d37 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorCommandSurfaceTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorCommandSurfaceTests.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using StellaOps.Cryptography; using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest; using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; @@ -36,15 +37,49 @@ public sealed class DescriptorCommandSurfaceTests var manifestOutputPath = Path.Combine(temp.Path, "out", "surface-manifest.json"); var repoRoot = TestPathHelper.FindRepositoryRoot(); - var manifestDirectory = Path.Combine(repoRoot, "src", "Scanner", "StellaOps.Scanner.Sbomer.BuildXPlugin"); - var pluginAssembly = typeof(BuildxPluginManifest).Assembly.Location; + var normalizedRoot = repoRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var rootFolder = Path.GetFileName(normalizedRoot); + var actualRepoRoot = string.Equals(rootFolder, "src", StringComparison.OrdinalIgnoreCase) + ? Directory.GetParent(normalizedRoot)?.FullName ?? normalizedRoot + : normalizedRoot; + var sourceRoot = string.Equals(rootFolder, "src", StringComparison.OrdinalIgnoreCase) + ? normalizedRoot + : Path.Combine(actualRepoRoot, "src"); + + var pluginProjectRoot = Path.Combine(sourceRoot, "Scanner", "StellaOps.Scanner.Sbomer.BuildXPlugin"); + + var manifestDirectoryCandidates = new[] + { + Path.Combine(actualRepoRoot, "plugins", "scanner", "buildx", "StellaOps.Scanner.Sbomer.BuildXPlugin"), + pluginProjectRoot + }; + + var manifestDirectory = manifestDirectoryCandidates.FirstOrDefault(Directory.Exists) + ?? throw new DirectoryNotFoundException( + $"BuildX manifest directory not found under '{string.Join("', '", manifestDirectoryCandidates)}'."); + var testsOutputDirectory = Path.GetDirectoryName(typeof(DescriptorCommandSurfaceTests).Assembly.Location) + ?? throw new InvalidOperationException("Unable to resolve test assembly directory."); + var targetFramework = new DirectoryInfo(testsOutputDirectory).Name; + var configuration = Directory.GetParent(testsOutputDirectory)?.Name ?? "Debug"; + + var pluginAssembly = Path.Combine( + pluginProjectRoot, + "bin", + configuration, + targetFramework, + "StellaOps.Scanner.Sbomer.BuildXPlugin.dll"); + + if (!File.Exists(pluginAssembly)) + { + throw new FileNotFoundException($"BuildX plug-in assembly not found at '{pluginAssembly}'."); + } var psi = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - WorkingDirectory = repoRoot + WorkingDirectory = actualRepoRoot }; psi.ArgumentList.Add(pluginAssembly); @@ -82,7 +117,9 @@ public sealed class DescriptorCommandSurfaceTests var descriptor = JsonSerializer.Deserialize(stdout, new JsonSerializerOptions(JsonSerializerDefaults.Web)); Assert.NotNull(descriptor); Assert.Equal("stellaops.buildx.descriptor.v1", descriptor!.Schema); - Assert.Equal("sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c", descriptor.Artifact.Digest); + var hash = CryptoHashFactory.CreateDefault(); + var expectedDigest = ComputeSha256Digest(hash, sbomPath); + Assert.Equal(expectedDigest, descriptor.Artifact.Digest); Assert.Contains("surface manifest stored", stderr, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(manifestOutputPath)); @@ -109,14 +146,21 @@ public sealed class DescriptorCommandSurfaceTests throw new InvalidOperationException($"Unsupported CAS URI {casUri}."); } - var slashIndex = casUri.IndexOf(/, prefix.Length); + var slashIndex = casUri.IndexOf('/', prefix.Length); if (slashIndex < 0) { throw new InvalidOperationException($"CAS URI {casUri} does not contain a bucket path."); } var relative = casUri[(slashIndex + 1)..]; - var localPath = Path.Combine(casRoot, relative.Replace(/, Path.DirectorySeparatorChar)); + var localPath = Path.Combine(casRoot, relative.Replace('/', Path.DirectorySeparatorChar)); return localPath; } + + private static string ComputeSha256Digest(ICryptoHash hash, string filePath) + { + var bytes = File.ReadAllBytes(filePath); + var digest = hash.ComputeHash(bytes, HashAlgorithms.Sha256); + return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}"; + } } diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGeneratorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGeneratorTests.cs index 1c3343648..1bd3d1fde 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGeneratorTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGeneratorTests.cs @@ -7,7 +7,8 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Time.Testing; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; +using StellaOps.Cryptography; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; using Xunit; @@ -23,7 +24,7 @@ public sealed class DescriptorGeneratorTests await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}"); var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); - var generator = new DescriptorGenerator(fakeTime); + var generator = CreateGenerator(fakeTime); var request = new DescriptorRequest { @@ -72,7 +73,7 @@ public sealed class DescriptorGeneratorTests await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}"); var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); - var generator = new DescriptorGenerator(fakeTime); + var generator = CreateGenerator(fakeTime); var request = new DescriptorRequest { @@ -109,7 +110,7 @@ public sealed class DescriptorGeneratorTests await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}"); var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); - var generator = new DescriptorGenerator(fakeTime); + var generator = CreateGenerator(fakeTime); var baseline = new DescriptorRequest { @@ -133,7 +134,10 @@ public sealed class DescriptorGeneratorTests Assert.NotEqual(baselineDocument.Provenance.ExpectedDsseSha256, variantDocument.Provenance.ExpectedDsseSha256); } - private static string ComputeSha256File(string path) + private static DescriptorGenerator CreateGenerator(TimeProvider timeProvider) + => new(timeProvider, CryptoHashFactory.CreateDefault()); + + private static string ComputeSha256File(string path) { using var stream = File.OpenRead(path); var hash = SHA256.HashData(stream); diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGoldenTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGoldenTests.cs index b5ce49b74..69917df43 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGoldenTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Descriptor/DescriptorGoldenTests.cs @@ -7,8 +7,9 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Time.Testing; -using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; +using Microsoft.Extensions.Time.Testing; +using StellaOps.Cryptography; +using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; using Xunit; @@ -48,7 +49,7 @@ public sealed class DescriptorGoldenTests }.Validate(); var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); - var generator = new DescriptorGenerator(fakeTime); + var generator = CreateGenerator(fakeTime); var document = await generator.CreateAsync(request, CancellationToken.None); var actualJson = JsonSerializer.Serialize(document, SerializerOptions); var normalizedJson = NormalizeDescriptorJson(actualJson, Path.GetFileName(sbomPath)); @@ -129,4 +130,6 @@ public sealed class DescriptorGoldenTests break; } } -} + private static DescriptorGenerator CreateGenerator(TimeProvider timeProvider) + => new(timeProvider, CryptoHashFactory.CreateDefault()); +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Surface/SurfaceManifestWriterTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Surface/SurfaceManifestWriterTests.cs index 5850c8222..a9b480f18 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Surface/SurfaceManifestWriterTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Sbomer.BuildXPlugin.Tests/Surface/SurfaceManifestWriterTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using StellaOps.Cryptography; using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface; using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; using Xunit; @@ -42,7 +43,7 @@ public sealed class SurfaceManifestWriterTests EntryTraceNdjsonPath: ndjsonPath, ManifestOutputPath: manifestOutputPath); - var writer = new SurfaceManifestWriter(TimeProvider.System); + var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault()); var result = await writer.WriteAsync(options, CancellationToken.None); Assert.NotNull(result); @@ -88,7 +89,7 @@ public sealed class SurfaceManifestWriterTests EntryTraceNdjsonPath: null, ManifestOutputPath: null); - var writer = new SurfaceManifestWriter(TimeProvider.System); + var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault()); var result = await writer.WriteAsync(options, CancellationToken.None); Assert.Null(result); } diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/CompositeScanAnalyzerDispatcherTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/CompositeScanAnalyzerDispatcherTests.cs index 2041761a9..1ce9a9f2a 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/CompositeScanAnalyzerDispatcherTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/CompositeScanAnalyzerDispatcherTests.cs @@ -22,7 +22,8 @@ using StellaOps.Scanner.Surface.Secrets; using StellaOps.Scanner.Surface.Validation; using StellaOps.Scanner.Worker.Diagnostics; using StellaOps.Scanner.Worker.Processing; -using Xunit; +using StellaOps.Scanner.Worker.Tests.TestInfrastructure; +using Xunit; using WorkerOptions = StellaOps.Scanner.Worker.Options.ScannerWorkerOptions; namespace StellaOps.Scanner.Worker.Tests; @@ -108,7 +109,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests languageCatalog, options, loggerFactory.CreateLogger(), - metrics); + metrics, + new TestCryptoHash()); var lease = new TestJobLease(metadata); var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None); diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/EntryTraceExecutionServiceTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/EntryTraceExecutionServiceTests.cs index d8cc47e83..5fa97da8c 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/EntryTraceExecutionServiceTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/EntryTraceExecutionServiceTests.cs @@ -19,6 +19,7 @@ using StellaOps.Scanner.Surface.Secrets; using StellaOps.Scanner.Surface.Validation; using StellaOps.Scanner.Worker.Options; using StellaOps.Scanner.Worker.Processing; +using StellaOps.Scanner.Worker.Tests.TestInfrastructure; using Xunit; namespace StellaOps.Scanner.Worker.Tests; @@ -166,7 +167,8 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable ISurfaceCache? surfaceCache = null, ISurfaceValidatorRunner? surfaceValidator = null, ISurfaceSecretProvider? surfaceSecrets = null, - ISurfaceEnvironment? surfaceEnvironment = null) + ISurfaceEnvironment? surfaceEnvironment = null, + ICryptoHash? hash = null) { var workerOptions = new ScannerWorkerOptions(); var entryTraceOptions = new EntryTraceAnalyzerOptions(); @@ -176,6 +178,7 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable surfaceCache ??= new InMemorySurfaceCache(); surfaceValidator ??= new NoopSurfaceValidatorRunner(); surfaceSecrets ??= new StubSurfaceSecretProvider(); + hash ??= new TestCryptoHash(); var serviceProvider = new ServiceCollection() .AddSingleton(surfaceEnvironment) .BuildServiceProvider(); @@ -192,7 +195,8 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable surfaceEnvironment, surfaceCache, surfaceSecrets, - serviceProvider); + serviceProvider, + hash); } private static ScanJobContext CreateContext(IReadOnlyDictionary metadata) diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs index dbadb7cb8..d68210070 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs @@ -7,7 +7,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using System.Security.Cryptography; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.EntryTrace; @@ -18,6 +17,7 @@ using StellaOps.Scanner.Worker.Processing; using StellaOps.Scanner.Worker.Processing.Surface; using StellaOps.Scanner.Worker.Tests.TestInfrastructure; using Xunit; +using StellaOps.Cryptography; namespace StellaOps.Scanner.Worker.Tests; @@ -34,12 +34,14 @@ public sealed class SurfaceManifestStageExecutorTests using var listener = new WorkerMeterListener(); listener.Start(); + var hash = new DefaultCryptoHash(); var executor = new SurfaceManifestStageExecutor( publisher, cache, environment, metrics, - NullLogger.Instance); + NullLogger.Instance, + hash); var context = CreateContext(); @@ -68,12 +70,14 @@ public sealed class SurfaceManifestStageExecutorTests using var listener = new WorkerMeterListener(); listener.Start(); + var hash = new DefaultCryptoHash(); var executor = new SurfaceManifestStageExecutor( publisher, cache, environment, metrics, - NullLogger.Instance); + NullLogger.Instance, + hash); var context = CreateContext(); PopulateAnalysis(context); diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/TestInfrastructure/TestCryptoHash.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/TestInfrastructure/TestCryptoHash.cs new file mode 100644 index 000000000..abde7d566 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/TestInfrastructure/TestCryptoHash.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Cryptography; + +namespace StellaOps.Scanner.Worker.Tests.TestInfrastructure; + +internal sealed class TestCryptoHash : ICryptoHash +{ + public byte[] ComputeHash(ReadOnlySpan data, string? algorithmId = null) + { + using var algorithm = CreateAlgorithm(algorithmId); + return algorithm.ComputeHash(data.ToArray()); + } + + public string ComputeHashHex(ReadOnlySpan data, string? algorithmId = null) + => Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant(); + + public string ComputeHashBase64(ReadOnlySpan data, string? algorithmId = null) + => Convert.ToBase64String(ComputeHash(data, algorithmId)); + + public async ValueTask ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) + { + using var algorithm = CreateAlgorithm(algorithmId); + await using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + return algorithm.ComputeHash(buffer.ToArray()); + } + + public async ValueTask ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) + { + var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static HashAlgorithm CreateAlgorithm(string? algorithmId) + { + return algorithmId?.ToUpperInvariant() switch + { + null or "" or HashAlgorithms.Sha256 => SHA256.Create(), + HashAlgorithms.Sha512 => SHA512.Create(), + _ => throw new NotSupportedException($"Test crypto hash does not support algorithm {algorithmId}.") + }; + } +} diff --git a/src/Scheduler/__Libraries/StellaOps.Scheduler.Worker/TASKS.md b/src/Scheduler/__Libraries/StellaOps.Scheduler.Worker/TASKS.md index 8fc2552e1..6dcfe4d17 100644 --- a/src/Scheduler/__Libraries/StellaOps.Scheduler.Worker/TASKS.md +++ b/src/Scheduler/__Libraries/StellaOps.Scheduler.Worker/TASKS.md @@ -2,6 +2,8 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| +| SCHED-WORKER-20-301 | DOING (2025-11-07) | Scheduler Worker Guild | POLICY-ENGINE-20-001 | Schedule policy runs via API with idempotent job tracking (policy id + target digest set), retries/backoff, and persisted run metadata for Console/CLI consumption. | `/scheduler/policy/runs` returns deterministic job IDs, status endpoints reflect progress/cancellations, retries/backoff covered by integration tests, and docs capture the API/metadata contract. | +> 2025-11-07: DTOs finalized with Web guild; policy-run targeting service replay tests passing, wiring REST surface next. | SCHED-SURFACE-01 | TODO | Scheduler Worker Guild | SURFACE-FS-02, SCANNER-SURFACE-02 | Evaluate Surface.FS pointers when planning delta scans to avoid redundant work and prioritise drift-triggered assets. | Planner reads Surface.FS manifests; regression tests cover cache hits/misses; documentation updated. | | SCHED-SURFACE-02 | TODO | Scheduler Worker Guild, Surface FS Guild | SURFACE-FS-02, SCHED-SURFACE-01 | Integrate Surface manifest reader to prefetch CAS manifests before scheduling reruns and persist pointer metadata alongside run plans. See `docs/modules/scanner/design/surface-fs-consumers.md` §3 for checklist. | Prefetch pipeline prevents redundant scans; scheduler persists manifest URIs/digests; integration tests cover cache hit/miss fallbacks and telemetry wiring. | diff --git a/src/Signals/StellaOps.Signals/Models/ReachabilityFactDocument.cs b/src/Signals/StellaOps.Signals/Models/ReachabilityFactDocument.cs new file mode 100644 index 000000000..1ba887303 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Models/ReachabilityFactDocument.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace StellaOps.Signals.Models; + +public sealed class ReachabilityFactDocument +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("callgraphId")] + public string CallgraphId { get; set; } = string.Empty; + + [BsonElement("subject")] + public ReachabilitySubject Subject { get; set; } = new(); + + [BsonElement("entryPoints")] + public List EntryPoints { get; set; } = new(); + + [BsonElement("states")] + public List States { get; set; } = new(); + + [BsonElement("metadata")] + [BsonIgnoreIfNull] + public Dictionary? Metadata { get; set; } + + [BsonElement("computedAt")] + public DateTimeOffset ComputedAt { get; set; } + + [BsonElement("subjectKey")] + [BsonRequired] + public string SubjectKey { get; set; } = string.Empty; +} + +public sealed class ReachabilityStateDocument +{ + [BsonElement("target")] + public string Target { get; set; } = string.Empty; + + [BsonElement("reachable")] + public bool Reachable { get; set; } + + [BsonElement("confidence")] + public double Confidence { get; set; } + + [BsonElement("path")] + public List Path { get; set; } = new(); + + [BsonElement("evidence")] + public ReachabilityEvidenceDocument Evidence { get; set; } = new(); +} + +public sealed class ReachabilityEvidenceDocument +{ + [BsonElement("runtimeHits")] + public List RuntimeHits { get; set; } = new(); + + [BsonElement("blockedEdges")] + [BsonIgnoreIfNull] + public List? BlockedEdges { get; set; } +} + +public sealed class ReachabilitySubject +{ + [BsonElement("imageDigest")] + [BsonIgnoreIfNull] + public string? ImageDigest { get; set; } + + [BsonElement("component")] + [BsonIgnoreIfNull] + public string? Component { get; set; } + + [BsonElement("version")] + [BsonIgnoreIfNull] + public string? Version { get; set; } + + [BsonElement("scanId")] + [BsonIgnoreIfNull] + public string? ScanId { get; set; } + + public string ToSubjectKey() + { + if (!string.IsNullOrWhiteSpace(ScanId)) + { + return ScanId!; + } + + if (!string.IsNullOrWhiteSpace(ImageDigest)) + { + return ImageDigest!; + } + + return string.Join('|', Component ?? string.Empty, Version ?? string.Empty).Trim('|'); + } +} diff --git a/src/Signals/StellaOps.Signals/Models/ReachabilityRecomputeRequest.cs b/src/Signals/StellaOps.Signals/Models/ReachabilityRecomputeRequest.cs new file mode 100644 index 000000000..c1e1e723b --- /dev/null +++ b/src/Signals/StellaOps.Signals/Models/ReachabilityRecomputeRequest.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace StellaOps.Signals.Models; + +public sealed class ReachabilityRecomputeRequest +{ + public string CallgraphId { get; set; } = string.Empty; + + public ReachabilitySubject Subject { get; set; } = new(); + + public List EntryPoints { get; set; } = new(); + + public List Targets { get; set; } = new(); + + public List? RuntimeHits { get; set; } + + public List? BlockedEdges { get; set; } + + public Dictionary? Metadata { get; set; } +} + +public sealed class ReachabilityBlockedEdge +{ + public string From { get; set; } = string.Empty; + public string To { get; set; } = string.Empty; +} diff --git a/src/Signals/StellaOps.Signals/Options/SignalsMongoOptions.cs b/src/Signals/StellaOps.Signals/Options/SignalsMongoOptions.cs index 90461c022..80446ee23 100644 --- a/src/Signals/StellaOps.Signals/Options/SignalsMongoOptions.cs +++ b/src/Signals/StellaOps.Signals/Options/SignalsMongoOptions.cs @@ -17,10 +17,15 @@ public sealed class SignalsMongoOptions /// public string Database { get; set; } = "signals"; - /// - /// Collection name storing normalized callgraphs. - /// - public string CallgraphsCollection { get; set; } = "callgraphs"; + /// + /// Collection name storing normalized callgraphs. + /// + public string CallgraphsCollection { get; set; } = "callgraphs"; + + /// + /// Collection name storing reachability facts. + /// + public string ReachabilityFactsCollection { get; set; } = "reachability_facts"; /// /// Validates the configured values. @@ -37,9 +42,14 @@ public sealed class SignalsMongoOptions throw new InvalidOperationException("Signals Mongo database name must be configured."); } - if (string.IsNullOrWhiteSpace(CallgraphsCollection)) - { - throw new InvalidOperationException("Signals callgraph collection name must be configured."); - } - } -} + if (string.IsNullOrWhiteSpace(CallgraphsCollection)) + { + throw new InvalidOperationException("Signals callgraph collection name must be configured."); + } + + if (string.IsNullOrWhiteSpace(ReachabilityFactsCollection)) + { + throw new InvalidOperationException("Signals reachability fact collection name must be configured."); + } + } +} diff --git a/src/Signals/StellaOps.Signals/Parsing/SimpleJsonCallgraphParser.cs b/src/Signals/StellaOps.Signals/Parsing/SimpleJsonCallgraphParser.cs index e416f9c52..715c8a020 100644 --- a/src/Signals/StellaOps.Signals/Parsing/SimpleJsonCallgraphParser.cs +++ b/src/Signals/StellaOps.Signals/Parsing/SimpleJsonCallgraphParser.cs @@ -9,9 +9,9 @@ using StellaOps.Signals.Models; namespace StellaOps.Signals.Parsing; /// -/// Simple JSON-based callgraph parser used for initial language coverage. -/// -internal sealed class SimpleJsonCallgraphParser : ICallgraphParser +/// Simple JSON-based callgraph parser used for initial language coverage. +/// +public sealed class SimpleJsonCallgraphParser : ICallgraphParser { private readonly JsonSerializerOptions serializerOptions; @@ -27,93 +27,160 @@ internal sealed class SimpleJsonCallgraphParser : ICallgraphParser public string Language { get; } - public async Task ParseAsync(Stream artifactStream, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(artifactStream); + public async Task ParseAsync(Stream artifactStream, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(artifactStream); + + using var document = await JsonDocument.ParseAsync(artifactStream, cancellationToken: cancellationToken).ConfigureAwait(false); + var root = document.RootElement; + + if (TryParseLegacy(root, out var legacyResult)) + { + return legacyResult; + } + + if (TryParseSchemaV1(root, out var schemaResult)) + { + return schemaResult; + } + + throw new CallgraphParserValidationException("Callgraph artifact payload is empty or missing required fields."); + } + + private static bool TryParseLegacy(JsonElement root, out CallgraphParseResult result) + { + result = default!; + + if (!root.TryGetProperty("graph", out var graphElement)) + { + return false; + } + + var nodesElement = graphElement.GetProperty("nodes"); + var edgesElement = graphElement.TryGetProperty("edges", out var edgesValue) ? edgesValue : default; + + var nodes = new List(nodesElement.GetArrayLength()); + foreach (var nodeElement in nodesElement.EnumerateArray()) + { + var id = nodeElement.GetProperty("id").GetString(); + if (string.IsNullOrWhiteSpace(id)) + { + throw new CallgraphParserValidationException("Callgraph node is missing an id."); + } + + nodes.Add(new CallgraphNode( + Id: id.Trim(), + Name: nodeElement.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? id.Trim() : id.Trim(), + Kind: nodeElement.TryGetProperty("kind", out var kindEl) ? kindEl.GetString() ?? "function" : "function", + Namespace: nodeElement.TryGetProperty("namespace", out var nsEl) ? nsEl.GetString() : null, + File: nodeElement.TryGetProperty("file", out var fileEl) ? fileEl.GetString() : null, + Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null)); + } + + var edges = new List(); + if (edgesElement.ValueKind == JsonValueKind.Array) + { + foreach (var edgeElement in edgesElement.EnumerateArray()) + { + var source = edgeElement.GetProperty("source").GetString(); + var target = edgeElement.GetProperty("target").GetString(); + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target)) + { + throw new CallgraphParserValidationException("Callgraph edge requires both source and target."); + } + + var type = edgeElement.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "call" : "call"; + edges.Add(new CallgraphEdge(source.Trim(), target.Trim(), type)); + } + } + + var formatVersion = root.TryGetProperty("formatVersion", out var versionEl) + ? versionEl.GetString() + : null; + + result = new CallgraphParseResult(nodes, edges, string.IsNullOrWhiteSpace(formatVersion) ? "1.0" : formatVersion!.Trim()); + return true; + } + + private static bool TryParseSchemaV1(JsonElement root, out CallgraphParseResult result) + { + result = default!; + + if (!root.TryGetProperty("nodes", out var nodesElement) && !root.TryGetProperty("edges", out _)) + { + return false; + } + + var nodes = new List(); + if (nodesElement.ValueKind == JsonValueKind.Array) + { + foreach (var nodeElement in nodesElement.EnumerateArray()) + { + var id = nodeElement.TryGetProperty("sid", out var sidEl) ? sidEl.GetString() : nodeElement.GetProperty("id").GetString(); + if (string.IsNullOrWhiteSpace(id)) + { + throw new CallgraphParserValidationException("Callgraph node is missing an id."); + } + + nodes.Add(new CallgraphNode( + Id: id.Trim(), + Name: nodeElement.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? id.Trim() : id.Trim(), + Kind: nodeElement.TryGetProperty("kind", out var kindEl) ? kindEl.GetString() ?? "function" : "function", + Namespace: nodeElement.TryGetProperty("namespace", out var nsEl) ? nsEl.GetString() : null, + File: nodeElement.TryGetProperty("file", out var fileEl) ? fileEl.GetString() : null, + Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null)); + } + } + + if (!root.TryGetProperty("edges", out var edgesElement) || edgesElement.ValueKind != JsonValueKind.Array) + { + edgesElement = default; + } + + var edges = new List(); + if (edgesElement.ValueKind == JsonValueKind.Array) + { + foreach (var edgeElement in edgesElement.EnumerateArray()) + { + var from = edgeElement.TryGetProperty("from", out var fromEl) ? fromEl.GetString() : edgeElement.GetProperty("source").GetString(); + var to = edgeElement.TryGetProperty("to", out var toEl) ? toEl.GetString() : edgeElement.GetProperty("target").GetString(); + if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to)) + { + throw new CallgraphParserValidationException("Callgraph edge requires both source and target."); + } + + var kind = edgeElement.TryGetProperty("kind", out var kindEl) + ? kindEl.GetString() ?? "call" + : edgeElement.TryGetProperty("type", out var typeEl) + ? typeEl.GetString() ?? "call" + : "call"; + + edges.Add(new CallgraphEdge(from.Trim(), to.Trim(), kind)); + } + } + + if (nodes.Count == 0) + { + // When nodes are omitted (framework overlay), derive them from the referenced edges. + var uniqueNodeIds = new HashSet(StringComparer.Ordinal); + foreach (var edge in edges) + { + uniqueNodeIds.Add(edge.SourceId); + uniqueNodeIds.Add(edge.TargetId); + } + + foreach (var nodeId in uniqueNodeIds) + { + nodes.Add(new CallgraphNode(nodeId, nodeId, "function", null, null, null)); + } + } + + var schemaVersion = root.TryGetProperty("schema_version", out var schemaEl) + ? schemaEl.GetString() + : "1.0"; + + result = new CallgraphParseResult(nodes, edges, string.IsNullOrWhiteSpace(schemaVersion) ? "1.0" : schemaVersion!.Trim()); + return true; + } - var payload = await JsonSerializer.DeserializeAsync( - artifactStream, - serializerOptions, - cancellationToken).ConfigureAwait(false); - - if (payload is null) - { - throw new CallgraphParserValidationException("Callgraph artifact payload is empty."); - } - - if (payload.Graph is null) - { - throw new CallgraphParserValidationException("Callgraph artifact is missing 'graph' section."); - } - - if (payload.Graph.Nodes is null || payload.Graph.Nodes.Count == 0) - { - throw new CallgraphParserValidationException("Callgraph artifact must include at least one node."); - } - - if (payload.Graph.Edges is null) - { - payload.Graph.Edges = new List(); - } - - var nodes = new List(payload.Graph.Nodes.Count); - foreach (var node in payload.Graph.Nodes) - { - if (string.IsNullOrWhiteSpace(node.Id)) - { - throw new CallgraphParserValidationException("Callgraph node is missing an id."); - } - - nodes.Add(new CallgraphNode( - Id: node.Id.Trim(), - Name: node.Name ?? node.Id.Trim(), - Kind: node.Kind ?? "function", - Namespace: node.Namespace, - File: node.File, - Line: node.Line)); - } - - var edges = new List(payload.Graph.Edges.Count); - foreach (var edge in payload.Graph.Edges) - { - if (string.IsNullOrWhiteSpace(edge.Source) || string.IsNullOrWhiteSpace(edge.Target)) - { - throw new CallgraphParserValidationException("Callgraph edge requires both source and target."); - } - - edges.Add(new CallgraphEdge(edge.Source.Trim(), edge.Target.Trim(), edge.Type ?? "call")); - } - - var formatVersion = string.IsNullOrWhiteSpace(payload.FormatVersion) ? "1.0" : payload.FormatVersion.Trim(); - return new CallgraphParseResult(nodes, edges, formatVersion); - } - - private sealed class RawCallgraphPayload - { - public string? FormatVersion { get; set; } - public RawCallgraphGraph? Graph { get; set; } - } - - private sealed class RawCallgraphGraph - { - public List? Nodes { get; set; } - public List? Edges { get; set; } - } - - private sealed class RawCallgraphNode - { - public string? Id { get; set; } - public string? Name { get; set; } - public string? Kind { get; set; } - public string? Namespace { get; set; } - public string? File { get; set; } - public int? Line { get; set; } - } - - private sealed class RawCallgraphEdge - { - public string? Source { get; set; } - public string? Target { get; set; } - public string? Type { get; set; } - } -} +} diff --git a/src/Signals/StellaOps.Signals/Persistence/ICallgraphRepository.cs b/src/Signals/StellaOps.Signals/Persistence/ICallgraphRepository.cs index 21b28721d..8ef3d518b 100644 --- a/src/Signals/StellaOps.Signals/Persistence/ICallgraphRepository.cs +++ b/src/Signals/StellaOps.Signals/Persistence/ICallgraphRepository.cs @@ -7,7 +7,9 @@ namespace StellaOps.Signals.Persistence; /// /// Persists normalized callgraphs. /// -public interface ICallgraphRepository -{ - Task UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken); -} +public interface ICallgraphRepository +{ + Task UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken); + + Task GetByIdAsync(string id, CancellationToken cancellationToken); +} diff --git a/src/Signals/StellaOps.Signals/Persistence/IReachabilityFactRepository.cs b/src/Signals/StellaOps.Signals/Persistence/IReachabilityFactRepository.cs new file mode 100644 index 000000000..257b455b6 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Persistence/IReachabilityFactRepository.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Persistence; + +public interface IReachabilityFactRepository +{ + Task UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken); + + Task GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken); +} diff --git a/src/Signals/StellaOps.Signals/Persistence/MongoCallgraphRepository.cs b/src/Signals/StellaOps.Signals/Persistence/MongoCallgraphRepository.cs index a4b15bc85..06e29e160 100644 --- a/src/Signals/StellaOps.Signals/Persistence/MongoCallgraphRepository.cs +++ b/src/Signals/StellaOps.Signals/Persistence/MongoCallgraphRepository.cs @@ -19,11 +19,11 @@ internal sealed class MongoCallgraphRepository : ICallgraphRepository this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(document); - - var filter = Builders.Filter.Eq(d => d.Component, document.Component) + public async Task UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(document); + + var filter = Builders.Filter.Eq(d => d.Component, document.Component) & Builders.Filter.Eq(d => d.Version, document.Version) & Builders.Filter.Eq(d => d.Language, document.Language); @@ -42,7 +42,18 @@ internal sealed class MongoCallgraphRepository : ICallgraphRepository document.Id = result.UpsertedId.AsObjectId.ToString(); } - logger.LogInformation("Upserted callgraph {Language}:{Component}:{Version} (id={Id}).", document.Language, document.Component, document.Version, document.Id); - return document; - } -} + logger.LogInformation("Upserted callgraph {Language}:{Component}:{Version} (id={Id}).", document.Language, document.Component, document.Version, document.Id); + return document; + } + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Callgraph id is required.", nameof(id)); + } + + var filter = Builders.Filter.Eq(d => d.Id, id); + return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Signals/StellaOps.Signals/Persistence/MongoReachabilityFactRepository.cs b/src/Signals/StellaOps.Signals/Persistence/MongoReachabilityFactRepository.cs new file mode 100644 index 000000000..c4b689dd9 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Persistence/MongoReachabilityFactRepository.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Persistence; + +internal sealed class MongoReachabilityFactRepository : IReachabilityFactRepository +{ + private readonly IMongoCollection collection; + private readonly ILogger logger; + + public MongoReachabilityFactRepository( + IMongoCollection collection, + ILogger logger) + { + this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(document); + if (string.IsNullOrWhiteSpace(document.SubjectKey)) + { + throw new ArgumentException("Subject key is required.", nameof(document)); + } + + var filter = Builders.Filter.Eq(d => d.SubjectKey, document.SubjectKey); + var options = new ReplaceOptions { IsUpsert = true }; + var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false); + if (result.UpsertedId != null) + { + document.Id = result.UpsertedId.AsObjectId.ToString(); + } + + logger.LogInformation("Upserted reachability fact for subject {SubjectKey} (callgraph={CallgraphId}).", document.SubjectKey, document.CallgraphId); + return document; + } + + public async Task GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(subjectKey)) + { + throw new ArgumentException("Subject key is required.", nameof(subjectKey)); + } + + var filter = Builders.Filter.Eq(d => d.SubjectKey, subjectKey); + return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Signals/StellaOps.Signals/Program.cs b/src/Signals/StellaOps.Signals/Program.cs index eb7a5bd23..798974836 100644 --- a/src/Signals/StellaOps.Signals/Program.cs +++ b/src/Signals/StellaOps.Signals/Program.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.IO; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using MongoDB.Driver; @@ -93,23 +94,34 @@ builder.Services.AddSingleton(sp => return mongoClient.GetDatabase(databaseName); }); -builder.Services.AddSingleton>(sp => -{ - var opts = sp.GetRequiredService>().Value; - var database = sp.GetRequiredService(); - var collection = database.GetCollection(opts.Mongo.CallgraphsCollection); - EnsureCallgraphIndexes(collection); - return collection; -}); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(new SimpleJsonCallgraphParser("java")); -builder.Services.AddSingleton(new SimpleJsonCallgraphParser("nodejs")); -builder.Services.AddSingleton(new SimpleJsonCallgraphParser("python")); -builder.Services.AddSingleton(new SimpleJsonCallgraphParser("go")); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton>(sp => +{ + var opts = sp.GetRequiredService>().Value; + var database = sp.GetRequiredService(); + var collection = database.GetCollection(opts.Mongo.CallgraphsCollection); + EnsureCallgraphIndexes(collection); + return collection; +}); + +builder.Services.AddSingleton>(sp => +{ + var opts = sp.GetRequiredService>().Value; + var database = sp.GetRequiredService(); + var collection = database.GetCollection(opts.Mongo.ReachabilityFactsCollection); + EnsureReachabilityFactIndexes(collection); + return collection; +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new SimpleJsonCallgraphParser("java")); +builder.Services.AddSingleton(new SimpleJsonCallgraphParser("nodejs")); +builder.Services.AddSingleton(new SimpleJsonCallgraphParser("python")); +builder.Services.AddSingleton(new SimpleJsonCallgraphParser("go")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); if (bootstrap.Authority.Enabled) { @@ -239,10 +251,40 @@ signalsGroup.MapPost("/runtime-facts", (HttpContext context, SignalsOptions opti ? Results.StatusCode(StatusCodes.Status501NotImplemented) : failure ?? Results.Unauthorized()).WithName("SignalsRuntimeIngest"); -signalsGroup.MapPost("/reachability/recompute", (HttpContext context, SignalsOptions options) => - Program.TryAuthorize(context, SignalsPolicies.Admin, options.Authority.AllowAnonymousFallback, out var failure) - ? Results.StatusCode(StatusCodes.Status501NotImplemented) - : failure ?? Results.Unauthorized()).WithName("SignalsReachabilityRecompute"); +signalsGroup.MapPost("/reachability/recompute", async Task ( + HttpContext context, + SignalsOptions options, + ReachabilityRecomputeRequest request, + IReachabilityScoringService scoringService, + CancellationToken cancellationToken) => +{ + if (!Program.TryAuthorize(context, SignalsPolicies.Admin, options.Authority.AllowAnonymousFallback, out var failure)) + { + return failure ?? Results.Unauthorized(); + } + + try + { + var fact = await scoringService.RecomputeAsync(request, cancellationToken).ConfigureAwait(false); + return Results.Ok(new + { + fact.Id, + fact.CallgraphId, + subject = fact.Subject, + fact.EntryPoints, + fact.States, + fact.ComputedAt + }); + } + catch (ReachabilityScoringValidationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (ReachabilityCallgraphNotFoundException ex) + { + return Results.NotFound(new { error = ex.Message }); + } +}).WithName("SignalsReachabilityRecompute"); app.Run(); @@ -286,11 +328,11 @@ public partial class Program return false; } - internal static void EnsureCallgraphIndexes(IMongoCollection collection) - { - ArgumentNullException.ThrowIfNull(collection); - - try + internal static void EnsureCallgraphIndexes(IMongoCollection collection) + { + ArgumentNullException.ThrowIfNull(collection); + + try { var indexKeys = Builders.IndexKeys .Ascending(document => document.Component) @@ -307,7 +349,31 @@ public partial class Program } catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "IndexOptionsConflict", StringComparison.Ordinal)) { - // Index already exists with different options – ignore to keep startup idempotent. - } - } -} + // Index already exists with different options – ignore to keep startup idempotent. + } + } + + internal static void EnsureReachabilityFactIndexes(IMongoCollection collection) + { + ArgumentNullException.ThrowIfNull(collection); + + try + { + var subjectIndex = new CreateIndexModel( + Builders.IndexKeys.Ascending(doc => doc.SubjectKey), + new CreateIndexOptions { Name = "reachability_subject_key_unique", Unique = true }); + + collection.Indexes.CreateOne(subjectIndex); + + var callgraphIndex = new CreateIndexModel( + Builders.IndexKeys.Ascending(doc => doc.CallgraphId), + new CreateIndexOptions { Name = "reachability_callgraph_lookup" }); + + collection.Indexes.CreateOne(callgraphIndex); + } + catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "IndexOptionsConflict", StringComparison.Ordinal)) + { + // Ignore when indexes already exist with different options to keep startup idempotent. + } + } +} diff --git a/src/Signals/StellaOps.Signals/Properties/AssemblyInfo.cs b/src/Signals/StellaOps.Signals/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..e63cdfafd --- /dev/null +++ b/src/Signals/StellaOps.Signals/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Signals.Reachability.Tests")] +[assembly: InternalsVisibleTo("StellaOps.ScannerSignals.IntegrationTests")] diff --git a/src/Signals/StellaOps.Signals/Services/IReachabilityScoringService.cs b/src/Signals/StellaOps.Signals/Services/IReachabilityScoringService.cs new file mode 100644 index 000000000..16fec4bb0 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/IReachabilityScoringService.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Services; + +public interface IReachabilityScoringService +{ + Task RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken); +} diff --git a/src/Signals/StellaOps.Signals/Services/ReachabilityScoringService.cs b/src/Signals/StellaOps.Signals/Services/ReachabilityScoringService.cs new file mode 100644 index 000000000..6909322ca --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/ReachabilityScoringService.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using StellaOps.Signals.Models; +using StellaOps.Signals.Persistence; + +namespace StellaOps.Signals.Services; + +public sealed class ReachabilityScoringService : IReachabilityScoringService +{ + private const double ReachableConfidence = 0.75; + private const double UnreachableConfidence = 0.25; + private const double RuntimeBonus = 0.15; + private const double MaxConfidence = 0.99; + private const double MinConfidence = 0.05; + + private readonly ICallgraphRepository callgraphRepository; + private readonly IReachabilityFactRepository factRepository; + private readonly TimeProvider timeProvider; + private readonly ILogger logger; + + public ReachabilityScoringService( + ICallgraphRepository callgraphRepository, + IReachabilityFactRepository factRepository, + TimeProvider timeProvider, + ILogger logger) + { + this.callgraphRepository = callgraphRepository ?? throw new ArgumentNullException(nameof(callgraphRepository)); + this.factRepository = factRepository ?? throw new ArgumentNullException(nameof(factRepository)); + this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + ValidateRequest(request); + + var callgraph = await callgraphRepository.GetByIdAsync(request.CallgraphId, cancellationToken).ConfigureAwait(false); + if (callgraph is null) + { + throw new ReachabilityCallgraphNotFoundException(request.CallgraphId); + } + + IEnumerable blockedEdges = request.BlockedEdges is { Count: > 0 } list + ? list + : Array.Empty(); + var graph = BuildGraph(callgraph, blockedEdges); + var entryPoints = NormalizeEntryPoints(request.EntryPoints, graph.Nodes, graph.Inbound); + var targets = request.Targets.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.Ordinal).ToList(); + if (targets.Count == 0) + { + throw new ReachabilityScoringValidationException("At least one target symbol is required."); + } + + var runtimeHits = request.RuntimeHits?.Where(hit => !string.IsNullOrWhiteSpace(hit)) + .Select(hit => hit.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList() ?? new List(); + + var states = new List(targets.Count); + foreach (var target in targets) + { + var path = FindPath(entryPoints, target, graph.Adjacency); + var reachable = path is not null; + var confidence = reachable ? ReachableConfidence : UnreachableConfidence; + + var runtimeEvidence = runtimeHits.Where(hit => path?.Contains(hit, StringComparer.Ordinal) == true) + .ToList(); + if (runtimeEvidence.Count > 0) + { + confidence = Math.Min(MaxConfidence, confidence + RuntimeBonus); + } + + confidence = Math.Clamp(confidence, MinConfidence, MaxConfidence); + + states.Add(new ReachabilityStateDocument + { + Target = target, + Reachable = reachable, + Confidence = confidence, + Path = path ?? new List(), + Evidence = new ReachabilityEvidenceDocument + { + RuntimeHits = runtimeEvidence, + BlockedEdges = request.BlockedEdges?.Select(edge => $"{edge.From} -> {edge.To}").ToList() + } + }); + } + + var document = new ReachabilityFactDocument + { + CallgraphId = request.CallgraphId, + Subject = request.Subject, + EntryPoints = entryPoints, + States = states, + Metadata = request.Metadata, + ComputedAt = timeProvider.GetUtcNow(), + SubjectKey = request.Subject.ToSubjectKey() + }; + + logger.LogInformation("Computed reachability fact for subject {SubjectKey} with {StateCount} targets.", document.SubjectKey, states.Count); + return await factRepository.UpsertAsync(document, cancellationToken).ConfigureAwait(false); + } + + private static void ValidateRequest(ReachabilityRecomputeRequest request) + { + if (string.IsNullOrWhiteSpace(request.CallgraphId)) + { + throw new ReachabilityScoringValidationException("Callgraph id is required."); + } + + if (request.Subject is null) + { + throw new ReachabilityScoringValidationException("Subject is required."); + } + } + + private static ReachabilityGraph BuildGraph(CallgraphDocument document, IEnumerable blockedEdges) + { + var adjacency = new Dictionary>(StringComparer.Ordinal); + var inbound = new Dictionary>(StringComparer.Ordinal); + var blocked = new HashSet<(string From, string To)>(new ReachabilityBlockedEdgeComparer()); + foreach (var blockedEdge in blockedEdges) + { + if (!string.IsNullOrWhiteSpace(blockedEdge.From) && !string.IsNullOrWhiteSpace(blockedEdge.To)) + { + blocked.Add((blockedEdge.From.Trim(), blockedEdge.To.Trim())); + } + } + + foreach (var edge in document.Edges) + { + if (blocked.Contains((edge.SourceId, edge.TargetId))) + { + continue; + } + + if (!adjacency.TryGetValue(edge.SourceId, out var targets)) + { + targets = new HashSet(StringComparer.Ordinal); + adjacency[edge.SourceId] = targets; + } + + if (targets.Add(edge.TargetId) && !inbound.TryGetValue(edge.TargetId, out var sources)) + { + sources = new HashSet(StringComparer.Ordinal); + inbound[edge.TargetId] = sources; + } + + inbound[edge.TargetId].Add(edge.SourceId); + } + + var nodes = new HashSet(document.Nodes?.Select(n => n.Id) ?? Array.Empty(), StringComparer.Ordinal); + foreach (var pair in adjacency) + { + nodes.Add(pair.Key); + foreach (var neighbor in pair.Value) + { + nodes.Add(neighbor); + } + } + + return new ReachabilityGraph(nodes, adjacency, inbound); + } + + private static List NormalizeEntryPoints(IEnumerable requestedEntries, HashSet nodes, Dictionary> inbound) + { + var entries = requestedEntries? + .Where(entry => !string.IsNullOrWhiteSpace(entry)) + .Select(entry => entry.Trim()) + .Distinct(StringComparer.Ordinal) + .Where(nodes.Contains) + .ToList() ?? new List(); + + if (entries.Count > 0) + { + return entries; + } + + var inferred = nodes.Where(node => !inbound.ContainsKey(node)).ToList(); + if (inferred.Count == 0) + { + inferred.AddRange(nodes); + } + + return inferred; + } + + private static List? FindPath(IEnumerable entryPoints, string target, Dictionary> adjacency) + { + var queue = new Queue(); + var parents = new Dictionary(StringComparer.Ordinal); + var visited = new HashSet(StringComparer.Ordinal); + + foreach (var entry in entryPoints) + { + if (visited.Add(entry)) + { + queue.Enqueue(entry); + } + } + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (string.Equals(current, target, StringComparison.Ordinal)) + { + return BuildPath(current, parents); + } + + if (!adjacency.TryGetValue(current, out var neighbors)) + { + continue; + } + + foreach (var neighbor in neighbors) + { + if (visited.Add(neighbor)) + { + parents[neighbor] = current; + queue.Enqueue(neighbor); + } + } + } + + return null; + } + + private static List BuildPath(string target, Dictionary parents) + { + var path = new List(); + var current = target; + path.Add(current); + + while (parents.TryGetValue(current, out var prev)) + { + path.Add(prev); + current = prev; + } + + path.Reverse(); + return path; + } + + private sealed record ReachabilityGraph( + HashSet Nodes, + Dictionary> Adjacency, + Dictionary> Inbound); + + private sealed class ReachabilityBlockedEdgeComparer : IEqualityComparer<(string From, string To)> + { + public bool Equals((string From, string To) x, (string From, string To) y) + => string.Equals(x.From, y.From, StringComparison.Ordinal) + && string.Equals(x.To, y.To, StringComparison.Ordinal); + + public int GetHashCode((string From, string To) obj) + { + unchecked + { + return (StringComparer.Ordinal.GetHashCode(obj.From) * 397) + ^ StringComparer.Ordinal.GetHashCode(obj.To); + } + } + } +} + +public sealed class ReachabilityScoringValidationException : Exception +{ + public ReachabilityScoringValidationException(string message) : base(message) + { + } +} + +public sealed class ReachabilityCallgraphNotFoundException : Exception +{ + public ReachabilityCallgraphNotFoundException(string callgraphId) : base($"Callgraph '{callgraphId}' was not found.") + { + } +} diff --git a/src/Signals/StellaOps.Signals/TASKS.md b/src/Signals/StellaOps.Signals/TASKS.md index 2031c3e6b..42bac99ba 100644 --- a/src/Signals/StellaOps.Signals/TASKS.md +++ b/src/Signals/StellaOps.Signals/TASKS.md @@ -1,6 +1,10 @@ # Signals Service Task Board — Reachability v1 | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| +| SIGNALS-24-001 | DOING (2025-11-07) | Signals Guild, Authority Guild | AUTH-SIG-26-001 | Stand up Signals API skeleton with RBAC + health checks, sealed-mode config, and DPoP/mTLS plumbing; seed config/tests for `/facts` ingestion. | Host scaffold deployed; `/healthz` and `/facts` endpoints respond with tenant-enforced RBAC; integration tests cover token binding; docs outline bootstrap steps. | +> 2025-11-07: DPoP nonce store wired to Authority preview tenants; `/healthz` + `/facts` smoke tests passing in CI with sealed-mode env. +| SIGNALS-24-002 | DOING (2025-11-07) | Signals Guild | SIGNALS-24-001 | Implement callgraph ingestion/normalisation pipeline (Java/Node/Python/Go), persist artifacts to CAS, and expose retrieval APIs. | Parser fixtures recorded; storage writes deterministic; retries/backoff documented; integration tests cover dedupe and failure paths. | +> 2025-11-07: Java/Node ingestion harness writing CAS blobs locally; Python/Go parsers next along with Mongo upserts. > 2025-10-29: Skeleton live with scope policies, stub endpoints, integration tests. Sample config added under `etc/signals.yaml.sample`. > 2025-10-29: JSON parsers for java/nodejs/python/go implemented; artifacts stored on filesystem with SHA-256, callgraphs upserted into Mongo with unique index; integration tests cover success + malformed requests. | SIGNALS-24-003 | BLOCKED (2025-10-27) | Signals Guild, Runtime Guild | SIGNALS-24-001 | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance. | Endpoint ingests fixture batches; duplicates deduped; schema enforced; tests cover privacy filters. | @@ -9,3 +13,5 @@ > 2025-10-27: Upstream ingestion pipelines (SIGNALS-24-002/003) blocked; scoring engine cannot proceed. | SIGNALS-24-005 | BLOCKED (2025-10-27) | Signals Guild, Platform Events Guild | SIGNALS-24-004 | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. | Cache hit rate tracked; invalidations working; events delivered with idempotent ids; integration tests pass. | > 2025-10-27: Awaiting scoring engine and ingestion layers before wiring cache/events. +| SIGNALS-REACH-201-003 | DOING (2025-11-08) | Signals Guild | SIGNALS-24-002 | Normalize multi-language callgraphs + runtime facts into `reachability_graphs` CAS layout, expose `/graphs/{scanId}` APIs, and document schema validations. | Parser fixtures for JVM/.NET/Go/Node/Rust/Swift pass; CAS manifests stored; API integration tests cover RBAC/tenancy. | +| SIGNALS-REACH-201-004 | DOING (2025-11-08) | Signals Guild, Policy Guild | SIGNALS-24-004 | Build reachability scoring + cache pipeline (state/score/confidence), emit `signals.fact.updated` events, and provide policy-ready projections with reachability weights. | Engine produces deterministic outputs; Redis cache hit metrics tracked; Policy integration tests consume signals successfully. | diff --git a/src/StellaOps.sln b/src/StellaOps.sln index 1a8466ba7..8e8f3de0d 100644 --- a/src/StellaOps.sln +++ b/src/StellaOps.sln @@ -439,6 +439,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Ldap.Tests", "Authority\StellaOps.Authority\StellaOps.Authority.Plugin.Ldap.Tests\StellaOps.Authority.Plugin.Ldap.Tests.csproj", "{AAB54944-813D-4596-B6A9-F0014523F97D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{FB2C1275-6C67-403C-8F21-B07A48C74FE4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -2873,6 +2875,18 @@ Global {AAB54944-813D-4596-B6A9-F0014523F97D}.Release|x64.Build.0 = Release|Any CPU {AAB54944-813D-4596-B6A9-F0014523F97D}.Release|x86.ActiveCfg = Release|Any CPU {AAB54944-813D-4596-B6A9-F0014523F97D}.Release|x86.Build.0 = Release|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Debug|x64.Build.0 = Debug|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Debug|x86.Build.0 = Debug|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|Any CPU.Build.0 = Release|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|x64.ActiveCfg = Release|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|x64.Build.0 = Release|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|x86.ActiveCfg = Release|Any CPU + {FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3007,5 +3021,6 @@ Global {E036A05A-EAEF-4C4A-B6C5-9616983B5C04} = {41F15E67-7190-CF23-3BC4-77E87134CADD} {D913460C-2054-48F0-B274-894A94A8DD7E} = {D09AE309-2C35-6780-54D1-97CCC67DFFDE} {AAB54944-813D-4596-B6A9-F0014523F97D} = {D09AE309-2C35-6780-54D1-97CCC67DFFDE} + {FB2C1275-6C67-403C-8F21-B07A48C74FE4} = {41F15E67-7190-CF23-3BC4-77E87134CADD} EndGlobalSection EndGlobal diff --git a/src/Web/StellaOps.Web/TASKS.md b/src/Web/StellaOps.Web/TASKS.md index 87ff2bc48..428378662 100644 --- a/src/Web/StellaOps.Web/TASKS.md +++ b/src/Web/StellaOps.Web/TASKS.md @@ -68,7 +68,13 @@ | ID | Status | Owner(s) | Depends on | Notes | |----|--------|----------|------------|-------| -| WEB-CONSOLE-23-001 `Global posture endpoints` | TODO | BE-Base Platform Guild, Product Analytics Guild | CONCELIER-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001, POLICY-CONSOLE-23-001, SBOM-CONSOLE-23-001, SCHED-CONSOLE-23-001 | Provide consolidated `/console/dashboard` and `/console/filters` APIs returning tenant-scoped aggregates (findings by severity, VEX override counts, advisory deltas, run health, policy change log). Enforce AOC labelling, deterministic ordering, and cursor-based pagination for drill-down hints. | +| WEB-CONSOLE-23-001 `Global posture endpoints` | TODO | BE-Base Platform Guild, Product Analytics Guild | CONCELIER-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001, POLICY-CONSOLE-23-001, SBOM-CONSOLE-23-001, SCHED-CONSOLE-23-001 | Provide consolidated `/console/dashboard` and `/console/filters` APIs returning tenant-scoped aggregates (findings by severity, VEX override counts, advisory deltas, run health, policy change log). Enforce AOC labelling, deterministic ordering, and cursor-based pagination for drill-down hints. | +| CONSOLE-VULN-29-001 `Vulnerability workspace` | DOING (2025-11-08) | Console Guild, BE-Base Platform Guild | WEB-CONSOLE-23-001, CONCELIER-GRAPH-21-001 | Build `/console/vuln/*` endpoints and filters surfacing tenant-scoped findings with policy/VEX badges, deterministic pagination, and a11y-friendly metadata so Docs can capture UI workflows. | +> 2025-11-08: Engaging filter/badge implementation plus `/console/vuln/search` DTOs now that Signals + Scheduler prerequisites exist; deliver payloads for DOCS-AIAI-31-004 screenshots. +> 2025-11-08: Drafted HTTP contract + samples in `docs/api/console/workspaces.md` so Docs/UI can exercise `GET /console/vuln/findings` before backend lands. +| CONSOLE-VEX-30-001 `VEX evidence workspace` | DOING (2025-11-08) | Console Guild, BE-Base Platform Guild | WEB-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001 | Provide `/console/vex/*` APIs streaming VEX statements, justification summaries, and advisory links with filter/sort options plus SSE hooks for background refresh. | +> 2025-11-08: Spiking SSE controller + `/console/vex/events` feed to keep Advisory AI console doc work unblocked and coordinate with Scheduler Signals dependencies. +> 2025-11-08: SSE contract + sample NDJSON (`docs/api/console/samples/vex-statement-sse.ndjson`) published; awaiting backend scaffolding to hook Scheduler streams. | WEB-CONSOLE-23-002 `Live status & SSE proxy` | TODO | BE-Base Platform Guild, Scheduler Guild | SCHED-CONSOLE-23-001, DEVOPS-CONSOLE-23-001 | Expose `/console/status` polling endpoint and `/console/runs/{id}/stream` SSE/WebSocket proxy with heartbeat/backoff, queue lag metrics, and auth scope enforcement. Surface request IDs + retry headers. | | WEB-CONSOLE-23-003 `Evidence export orchestrator` | TODO | BE-Base Platform Guild, Policy Guild | EXPORT-CONSOLE-23-001, POLICY-CONSOLE-23-001 | Add `/console/exports` POST/GET routes coordinating evidence bundle creation, streaming CSV/JSON exports, checksum manifest retrieval, and signed attestation references. Ensure requests honor tenant + policy scopes and expose job tracking metadata. | | WEB-CONSOLE-23-004 `Global search router` | TODO | BE-Base Platform Guild | CONCELIER-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001, SBOM-CONSOLE-23-001 | Implement `/console/search` endpoint accepting CVE/GHSA/PURL/SBOM identifiers, performing fan-out queries with caching, ranking, and deterministic tie-breaking. Return typed results for Console navigation; respect result caps and latency SLOs. | diff --git a/src/Zastava/StellaOps.Zastava.Observer/TASKS.md b/src/Zastava/StellaOps.Zastava.Observer/TASKS.md index 0290203ff..dc7dea8a6 100644 --- a/src/Zastava/StellaOps.Zastava.Observer/TASKS.md +++ b/src/Zastava/StellaOps.Zastava.Observer/TASKS.md @@ -8,3 +8,5 @@ | ZASTAVA-SECRETS-01 | TODO | Zastava Observer Guild, Security Guild | SURFACE-SECRETS-02 | Retrieve CAS/attestation access via Surface.Secrets instead of inline secret stores. | Secrets resolved through shared provider; rotation/resilience tests pass. | > 2025-10-24: Observer unit tests pending; `dotnet restore` requires offline copies of `Google.Protobuf`, `Grpc.Net.Client`, `Grpc.Tools` in `local-nuget` before execution can be verified. + +| ZASTAVA-REACH-201-001 | TODO | Zastava Observer Guild | SIGNALS-24-001 | Stream runtime symbol hits + EntryTrace shell contexts to Signals `/runtime-facts`, attach build-id metadata, and emit CAS-backed trace blobs per scan/run. Update observer config/runbook references. | Runtime sampler unit/integration tests pass; ND-JSON batches accepted by Signals; docs + configs refreshed. | diff --git a/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj b/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj index d54fbbc92..aacdc970f 100644 --- a/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj +++ b/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj @@ -20,6 +20,9 @@ + + + -
\ No newline at end of file + diff --git a/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs b/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs index d4b8f2807..02e6a9674 100644 --- a/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs +++ b/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs @@ -84,6 +84,11 @@ public sealed class StellaOpsAuthorityOptions /// public AuthorityPluginSettings Plugins { get; } = new(); + /// + /// Sovereign cryptography configuration (provider registry + plugins). + /// + public StellaOpsCryptoOptions Crypto { get; } = new(); + /// /// Security-related configuration for the Authority host. /// diff --git a/src/__Libraries/StellaOps.Configuration/StellaOpsCryptoOptions.cs b/src/__Libraries/StellaOps.Configuration/StellaOpsCryptoOptions.cs new file mode 100644 index 000000000..615e14a2a --- /dev/null +++ b/src/__Libraries/StellaOps.Configuration/StellaOpsCryptoOptions.cs @@ -0,0 +1,20 @@ +using StellaOps.Cryptography; +using StellaOps.Cryptography.DependencyInjection; +using StellaOps.Cryptography.Plugin.CryptoPro; +using StellaOps.Cryptography.Plugin.Pkcs11Gost; + +namespace StellaOps.Configuration; + +/// +/// Shared crypto configuration (registry ordering + provider settings) consumed by hosts and tooling. +/// +public sealed class StellaOpsCryptoOptions +{ + public CryptoProviderRegistryOptions Registry { get; } = new(); + + public Pkcs11GostProviderOptions Pkcs11 { get; } = new(); + + public CryptoProGostProviderOptions CryptoPro { get; } = new(); + + public string DefaultHashAlgorithm { get; set; } = HashAlgorithms.Sha256; +} diff --git a/src/__Libraries/StellaOps.Configuration/StellaOpsCryptoServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Configuration/StellaOpsCryptoServiceCollectionExtensions.cs new file mode 100644 index 000000000..b03695fb6 --- /dev/null +++ b/src/__Libraries/StellaOps.Configuration/StellaOpsCryptoServiceCollectionExtensions.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using StellaOps.Cryptography; +using StellaOps.Cryptography.DependencyInjection; +using StellaOps.Cryptography.Plugin.CryptoPro; +using StellaOps.Cryptography.Plugin.Pkcs11Gost; + +namespace StellaOps.Configuration; + +public static class StellaOpsCryptoServiceCollectionExtensions +{ + public static IServiceCollection AddStellaOpsCrypto( + this IServiceCollection services, + StellaOpsCryptoOptions? options) + { + ArgumentNullException.ThrowIfNull(services); + + var resolved = options ?? new StellaOpsCryptoOptions(); + + services.AddStellaOpsCrypto(registryOptions => + { + ApplyRegistry(registryOptions, resolved.Registry); + }); + + services.AddPkcs11GostProvider(); + services.Configure(target => + { + CopyPkcs11Options(target, resolved.Pkcs11); + }); + + services.AddCryptoProGostProvider(); + services.Configure(target => + { + CopyCryptoProOptions(target, resolved.CryptoPro); + }); + + services.Configure(hash => + { + hash.DefaultAlgorithm = string.IsNullOrWhiteSpace(resolved.DefaultHashAlgorithm) + ? HashAlgorithms.Sha256 + : resolved.DefaultHashAlgorithm.Trim(); + }); + + return services; + } + + private static void ApplyRegistry( + CryptoProviderRegistryOptions target, + CryptoProviderRegistryOptions source) + { + target.ActiveProfile = source.ActiveProfile; + target.PreferredProviders.Clear(); + foreach (var provider in source.PreferredProviders) + { + if (!string.IsNullOrWhiteSpace(provider)) + { + target.PreferredProviders.Add(provider.Trim()); + } + } + + target.Profiles.Clear(); + foreach (var kvp in source.Profiles) + { + if (kvp.Value is null) + { + continue; + } + + var profile = new CryptoProviderProfileOptions(); + foreach (var provider in kvp.Value.PreferredProviders) + { + if (!string.IsNullOrWhiteSpace(provider)) + { + profile.PreferredProviders.Add(provider.Trim()); + } + } + + target.Profiles[kvp.Key] = profile; + } + } + + private static void CopyPkcs11Options(Pkcs11GostProviderOptions target, Pkcs11GostProviderOptions source) + { + target.Keys.Clear(); + foreach (var key in source.Keys) + { + target.Keys.Add(key.Clone()); + } + } + + private static void CopyCryptoProOptions(CryptoProGostProviderOptions target, CryptoProGostProviderOptions source) + { + target.Keys.Clear(); + foreach (var key in source.Keys) + { + target.Keys.Add(key.Clone()); + } + } +} diff --git a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoProviderRegistryOptions.cs b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoProviderRegistryOptions.cs index f730d9d4f..651aad169 100644 --- a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoProviderRegistryOptions.cs +++ b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoProviderRegistryOptions.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; namespace StellaOps.Cryptography.DependencyInjection; @@ -7,8 +10,52 @@ namespace StellaOps.Cryptography.DependencyInjection; /// public sealed class CryptoProviderRegistryOptions { + private readonly Dictionary profiles = + new(StringComparer.OrdinalIgnoreCase); + /// /// Ordered list of preferred provider names. Providers appearing here are consulted first. /// public IList PreferredProviders { get; } = new List(); + + /// + /// Optional active profile name (e.g. "ru-offline") that overrides . + /// + public string? ActiveProfile { get; set; } + + /// + /// Regional or environment-specific provider preference profiles. + /// + public IDictionary Profiles => profiles; + + public IReadOnlyList ResolvePreferredProviders() + { + static IReadOnlyList Normalise(IEnumerable items) + => new ReadOnlyCollection( + items.Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .ToArray()); + + if (!string.IsNullOrWhiteSpace(ActiveProfile) && + profiles.TryGetValue(ActiveProfile, out var profile) && + profile.PreferredProviders.Count > 0) + { + return Normalise(profile.PreferredProviders); + } + + if (PreferredProviders.Count > 0) + { + return Normalise(PreferredProviders); + } + + return Array.Empty(); + } +} + +public sealed class CryptoProviderProfileOptions +{ + /// + /// Ordered list of preferred provider names for the profile. + /// + public IList PreferredProviders { get; } = new List(); } diff --git a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs index 58851f33e..3e76d907f 100644 --- a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs +++ b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs @@ -45,11 +45,13 @@ public static class CryptoServiceCollectionExtensions services.TryAddEnumerable(ServiceDescriptor.Singleton()); #endif + services.TryAddSingleton(); + services.TryAddSingleton(sp => { var providers = sp.GetServices(); - var options = sp.GetService>(); - IEnumerable? preferred = options?.Value?.PreferredProviders; + var options = sp.GetService>(); + IEnumerable? preferred = options?.CurrentValue?.ResolvePreferredProviders(); return new CryptoProviderRegistry(providers, preferred); }); diff --git a/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj b/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj index 5e831f9dc..45c00d055 100644 --- a/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj @@ -5,8 +5,8 @@ enable - - + + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProCryptoServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProCryptoServiceCollectionExtensions.cs new file mode 100644 index 000000000..709aac94c --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProCryptoServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Cryptography.Plugin.CryptoPro; + +public static class CryptoProCryptoServiceCollectionExtensions +{ + public static IServiceCollection AddCryptoProGostProvider( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + if (configure is not null) + { + services.Configure(configure); + } + + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + return services; + } +} + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostCryptoProvider.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostCryptoProvider.cs new file mode 100644 index 000000000..29be75253 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostCryptoProvider.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Cryptography; +using StellaOps.Cryptography.Plugin.Pkcs11Gost; + +namespace StellaOps.Cryptography.Plugin.CryptoPro; + +public sealed class CryptoProGostCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics +{ + private readonly Pkcs11GostProviderCore core; + + public CryptoProGostCryptoProvider( + IOptions? optionsAccessor = null, + ILogger? logger = null) + { + var options = optionsAccessor?.Value ?? new CryptoProGostProviderOptions(); + var mappedKeys = new List(options.Keys.Count); + foreach (var key in options.Keys) + { + mappedKeys.Add(MapToPkcs11Options(key)); + } + + core = new Pkcs11GostProviderCore("ru.cryptopro.csp", mappedKeys, logger); + } + + public string Name => core.ProviderName; + + public bool Supports(CryptoCapability capability, string algorithmId) + { + if (capability is CryptoCapability.Signing or CryptoCapability.Verification) + { + return core.SupportsAlgorithm(algorithmId); + } + + return false; + } + + public IPasswordHasher GetPasswordHasher(string algorithmId) + => throw new NotSupportedException("CryptoPro provider does not expose password hashing."); + + public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference) + { + ArgumentNullException.ThrowIfNull(keyReference); + var entry = core.Resolve(keyReference.KeyId); + if (!string.Equals(entry.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.AlgorithmId}', not '{algorithmId}'."); + } + + return new Pkcs11GostSigner(entry); + } + + public void UpsertSigningKey(CryptoSigningKey signingKey) + => throw new NotSupportedException("CryptoPro keys are managed externally."); + + public bool RemoveSigningKey(string keyId) => false; + + public IReadOnlyCollection GetSigningKeys() + => Array.Empty(); + + public IEnumerable DescribeKeys() + => core.DescribeKeys(Name); + + private static Pkcs11GostKeyOptions MapToPkcs11Options(CryptoProGostKeyOptions source) + { + ArgumentNullException.ThrowIfNull(source); + + return new Pkcs11GostKeyOptions + { + KeyId = source.KeyId, + Algorithm = source.Algorithm, + LibraryPath = source.LibraryPath, + SlotId = source.SlotId, + TokenLabel = source.TokenLabel, + PrivateKeyLabel = source.ContainerLabel, + UserPin = source.UserPin, + UserPinEnvironmentVariable = source.UserPinEnvironmentVariable, + SignMechanismId = source.SignMechanismId, + CertificateThumbprint = source.CertificateThumbprint, + CertificateStoreLocation = source.CertificateStoreLocation.ToString(), + CertificateStoreName = source.CertificateStoreName.ToString() + }; + } +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostKeyOptions.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostKeyOptions.cs new file mode 100644 index 000000000..c6bab97b8 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostKeyOptions.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography.X509Certificates; +using StellaOps.Cryptography; + +namespace StellaOps.Cryptography.Plugin.CryptoPro; + +public sealed class CryptoProGostKeyOptions +{ + [Required] + public string KeyId { get; set; } = string.Empty; + + public string Algorithm { get; set; } = SignatureAlgorithms.GostR3410_2012_256; + + /// + /// PKCS#11 library path (typically cprocsp-pkcs11*.dll/so). + /// + [Required] + public string LibraryPath { get; set; } = string.Empty; + + public string? SlotId { get; set; } + + public string? TokenLabel { get; set; } + + public string? ContainerLabel { get; set; } + + public string? UserPin { get; set; } + + public string? UserPinEnvironmentVariable { get; set; } + + public uint? SignMechanismId { get; set; } + + public string CertificateThumbprint { get; set; } = string.Empty; + + public StoreLocation CertificateStoreLocation { get; set; } = StoreLocation.CurrentUser; + + public StoreName CertificateStoreName { get; set; } = StoreName.My; + + public CryptoProGostKeyOptions Clone() + => new() + { + KeyId = KeyId, + Algorithm = Algorithm, + LibraryPath = LibraryPath, + SlotId = SlotId, + TokenLabel = TokenLabel, + ContainerLabel = ContainerLabel, + UserPin = UserPin, + UserPinEnvironmentVariable = UserPinEnvironmentVariable, + SignMechanismId = SignMechanismId, + CertificateThumbprint = CertificateThumbprint, + CertificateStoreLocation = CertificateStoreLocation, + CertificateStoreName = CertificateStoreName + }; +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostProviderOptions.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostProviderOptions.cs new file mode 100644 index 000000000..09bbe2e4b --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/CryptoProGostProviderOptions.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace StellaOps.Cryptography.Plugin.CryptoPro; + +public sealed class CryptoProGostProviderOptions +{ + private readonly IList keys = new List(); + + /// + /// CryptoPro-backed keys managed by the provider. + /// + public IList Keys => keys; + + public CryptoProGostProviderOptions Clone() + { + var clone = new CryptoProGostProviderOptions(); + foreach (var key in keys) + { + clone.Keys.Add(key.Clone()); + } + + return clone; + } +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj new file mode 100644 index 000000000..758a954e5 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + preview + enable + enable + true + + + + + + + + + + + + + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/InternalsVisibleTo.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/InternalsVisibleTo.cs new file mode 100644 index 000000000..caf2f3b6e --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/InternalsVisibleTo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.CryptoPro")] + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/PemUtilities.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/PemUtilities.cs new file mode 100644 index 000000000..e10cf2728 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/PemUtilities.cs @@ -0,0 +1,29 @@ +using System; +using System.Text; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +internal static class PemUtilities +{ + public static string ExtractBody(string pem) + { + if (string.IsNullOrWhiteSpace(pem)) + { + throw new ArgumentException("PEM content is empty.", nameof(pem)); + } + + var builder = new StringBuilder(); + foreach (var line in pem.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("-----", StringComparison.Ordinal)) + { + continue; + } + + builder.Append(line.Trim()); + } + + return builder.ToString(); + } +} + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11CryptoServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11CryptoServiceCollectionExtensions.cs new file mode 100644 index 000000000..3a9b676e5 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11CryptoServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +public static class Pkcs11CryptoServiceCollectionExtensions +{ + public static IServiceCollection AddPkcs11GostProvider( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + if (configure is not null) + { + services.Configure(configure); + } + + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + return services; + } +} + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostCryptoProvider.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostCryptoProvider.cs new file mode 100644 index 000000000..11cbf469b --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostCryptoProvider.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Cryptography; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +public sealed class Pkcs11GostCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics +{ + private readonly Pkcs11GostProviderCore core; + + public Pkcs11GostCryptoProvider( + IOptions? optionsAccessor = null, + ILogger? logger = null) + { + var options = optionsAccessor?.Value ?? new Pkcs11GostProviderOptions(); + core = new Pkcs11GostProviderCore("ru.pkcs11", options.Keys, logger); + } + + public string Name => core.ProviderName; + + public bool Supports(CryptoCapability capability, string algorithmId) + { + if (capability is CryptoCapability.Signing or CryptoCapability.Verification) + { + return core.SupportsAlgorithm(algorithmId); + } + + return false; + } + + public IPasswordHasher GetPasswordHasher(string algorithmId) + => throw new NotSupportedException("PKCS#11 provider does not expose password hashing."); + + public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference) + { + ArgumentNullException.ThrowIfNull(keyReference); + var entry = core.Resolve(keyReference.KeyId); + if (!string.Equals(entry.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.AlgorithmId}', not '{algorithmId}'."); + } + + return new Pkcs11GostSigner(entry); + } + + public void UpsertSigningKey(CryptoSigningKey signingKey) + => throw new NotSupportedException("PKCS#11 keys are managed externally."); + + public bool RemoveSigningKey(string keyId) => false; + + public IReadOnlyCollection GetSigningKeys() + => Array.Empty(); + + public IEnumerable DescribeKeys() + => core.DescribeKeys(Name); +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyEntry.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyEntry.cs new file mode 100644 index 000000000..30d4efc9e --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyEntry.cs @@ -0,0 +1,42 @@ +using System; +using System.Globalization; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Security; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +internal sealed class Pkcs11GostKeyEntry +{ + public Pkcs11GostKeyEntry( + string keyId, + string algorithmId, + Pkcs11SessionOptions session, + X509Certificate2 certificate, + uint signMechanismId) + { + KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId)); + AlgorithmId = algorithmId ?? throw new ArgumentNullException(nameof(algorithmId)); + Session = session ?? throw new ArgumentNullException(nameof(session)); + Certificate = certificate ?? throw new ArgumentNullException(nameof(certificate)); + SignMechanismId = signMechanismId; + var bcCertificate = DotNetUtilities.FromX509Certificate(Certificate); + PublicKeyParameters = bcCertificate.GetPublicKey(); + } + + public string KeyId { get; } + + public string AlgorithmId { get; } + + public Pkcs11SessionOptions Session { get; } + + public X509Certificate2 Certificate { get; } + + public Org.BouncyCastle.Crypto.AsymmetricKeyParameter PublicKeyParameters { get; } + + public uint SignMechanismId { get; } + + public bool Is256 => string.Equals( + AlgorithmId, + StellaOps.Cryptography.SignatureAlgorithms.GostR3410_2012_256, + StringComparison.OrdinalIgnoreCase); +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyOptions.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyOptions.cs new file mode 100644 index 000000000..5f827d24c --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostKeyOptions.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; +using StellaOps.Cryptography; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +/// +/// Describes a PKCS#11-backed signing key. +/// +public sealed class Pkcs11GostKeyOptions +{ + /// + /// Logical identifier for the key (used as kid). + /// + [Required] + public string KeyId { get; set; } = string.Empty; + + /// + /// Signing algorithm identifier. + /// + public string Algorithm { get; set; } = SignatureAlgorithms.GostR3410_2012_256; + + /// + /// Absolute path to the PKCS#11 library (e.g. /usr/local/lib/librutokenecp.so). + /// + [Required] + public string LibraryPath { get; set; } = string.Empty; + + /// + /// Optional slot identifier (decimal or hexadecimal). Mutually exclusive with . + /// + public string? SlotId { get; set; } + + /// + /// Optional token label to locate the slot. Mutually exclusive with . + /// + public string? TokenLabel { get; set; } + + /// + /// Label identifying the private key object. + /// + public string? PrivateKeyLabel { get; set; } + + /// + /// Optional label for the certificate or public key object. + /// + public string? PublicKeyLabel { get; set; } + + /// + /// User PIN supplied inline (discouraged for production). + /// + public string? UserPin { get; set; } + + /// + /// Name of the environment variable containing the PIN (preferred). + /// + public string? UserPinEnvironmentVariable { get; set; } + + /// + /// Mechanism identifier used for signature operations (e.g. 0x00001255 for GOST12-256). + /// + public uint? SignMechanismId { get; set; } + + /// + /// Optional PEM/DER path for the X.509 certificate corresponding to the private key. + /// + public string? CertificatePath { get; set; } + + /// + /// Optional inline PEM certificate. + /// + public string? CertificatePem { get; set; } + + /// + /// Optional Windows/Linux store thumbprint identifier (when CertificatePath is not provided). + /// + public string? CertificateThumbprint { get; set; } + + /// + /// Optional store location (CurrentUser/LocalMachine). Defaults to CurrentUser. + /// + public string CertificateStoreLocation { get; set; } = "CurrentUser"; + + /// + /// Optional store name (My/Root/etc). Defaults to My. + /// + public string CertificateStoreName { get; set; } = "My"; + + public Pkcs11GostKeyOptions Clone() + => new() + { + KeyId = KeyId, + Algorithm = Algorithm, + LibraryPath = LibraryPath, + SlotId = SlotId, + TokenLabel = TokenLabel, + PrivateKeyLabel = PrivateKeyLabel, + PublicKeyLabel = PublicKeyLabel, + UserPin = UserPin, + UserPinEnvironmentVariable = UserPinEnvironmentVariable, + SignMechanismId = SignMechanismId, + CertificatePath = CertificatePath, + CertificatePem = CertificatePem, + CertificateThumbprint = CertificateThumbprint, + CertificateStoreLocation = CertificateStoreLocation, + CertificateStoreName = CertificateStoreName + }; +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderCore.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderCore.cs new file mode 100644 index 000000000..f96b6184c --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderCore.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using StellaOps.Cryptography; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +internal sealed class Pkcs11GostProviderCore +{ + private readonly Dictionary entries; + private readonly ILogger? logger; + private readonly string providerName; + + public Pkcs11GostProviderCore( + string providerName, + IEnumerable options, + ILogger? logger = null) + { + this.providerName = providerName; + this.logger = logger; + entries = new Dictionary(StringComparer.Ordinal); + + foreach (var keyOptions in options ?? Array.Empty()) + { + var entry = BuildEntry(keyOptions); + if (!entries.TryAdd(entry.KeyId, entry)) + { + throw new InvalidOperationException( + $"Duplicate PKCS#11 key identifier '{entry.KeyId}' configured for provider '{providerName}'."); + } + } + } + + public string ProviderName => providerName; + + public IReadOnlyDictionary Entries => entries; + + public bool SupportsAlgorithm(string algorithmId) + { + foreach (var entry in entries.Values) + { + if (string.Equals(entry.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + public Pkcs11GostKeyEntry Resolve(string keyId) + { + if (!entries.TryGetValue(keyId, out var entry)) + { + throw new KeyNotFoundException( + $"Signing key '{keyId}' is not registered with provider '{providerName}'."); + } + + return entry; + } + + private Pkcs11GostKeyEntry BuildEntry(Pkcs11GostKeyOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.KeyId)) + { + throw new InvalidOperationException("PKCS#11 key options require a non-empty keyId."); + } + + if (string.IsNullOrWhiteSpace(options.LibraryPath)) + { + throw new InvalidOperationException($"PKCS#11 key '{options.KeyId}' requires libraryPath."); + } + + var mechanism = options.SignMechanismId ?? + (string.Equals(options.Algorithm, SignatureAlgorithms.GostR3410_2012_512, StringComparison.OrdinalIgnoreCase) + ? Pkcs11Mechanisms.DefaultGost12_512Signature + : Pkcs11Mechanisms.DefaultGost12_256Signature); + + var session = new Pkcs11SessionOptions + { + LibraryPath = options.LibraryPath, + SlotId = options.SlotId, + TokenLabel = options.TokenLabel, + PrivateKeyLabel = options.PrivateKeyLabel, + PublicKeyLabel = options.PublicKeyLabel, + UserPin = options.UserPin, + UserPinEnvironmentVariable = options.UserPinEnvironmentVariable + }; + + var certificate = LoadCertificate(options); + + logger?.LogInformation( + "PKCS#11 key {KeyId} (algorithm {Algorithm}) registered for provider {Provider}", + options.KeyId, + options.Algorithm, + providerName); + + return new Pkcs11GostKeyEntry( + options.KeyId, + options.Algorithm, + session, + certificate, + mechanism); + } + + private static X509Certificate2 LoadCertificate(Pkcs11GostKeyOptions options) + { + if (!string.IsNullOrWhiteSpace(options.CertificatePem)) + { + var rawBytes = Convert.FromBase64String(PemUtilities.ExtractBody(options.CertificatePem)); + return X509CertificateLoader.LoadCertificate(rawBytes); + } + + if (!string.IsNullOrWhiteSpace(options.CertificatePath)) + { + if (!File.Exists(options.CertificatePath)) + { + throw new FileNotFoundException($"Certificate file '{options.CertificatePath}' was not found.", options.CertificatePath); + } + + return X509CertificateLoader.LoadCertificateFromFile(options.CertificatePath); + } + + if (!string.IsNullOrWhiteSpace(options.CertificateThumbprint)) + { + var location = Enum.TryParse(options.CertificateStoreLocation, ignoreCase: true, out StoreLocation parsedLocation) + ? parsedLocation + : StoreLocation.CurrentUser; + var storeName = Enum.TryParse(options.CertificateStoreName, ignoreCase: true, out StoreName parsedStore) + ? parsedStore + : StoreName.My; + using var store = new X509Store(storeName, location); + store.Open(OpenFlags.ReadOnly); + var thumbprint = options.CertificateThumbprint.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase) + .ToUpperInvariant(); + var matches = store.Certificates.Find( + X509FindType.FindByThumbprint, + thumbprint, + validOnly: false); + if (matches.Count == 0) + { + throw new InvalidOperationException( + $"Certificate with thumbprint '{thumbprint}' was not found in {location}/{storeName}."); + } + + return X509CertificateLoader.LoadCertificate(matches[0].RawData); + } + + throw new InvalidOperationException( + $"PKCS#11 key '{options.KeyId}' requires either certificatePath, certificatePem, or certificateThumbprint."); + } + + public IEnumerable DescribeKeys(string provider) + { + foreach (var entry in entries.Values) + { + yield return CreateDescriptor(provider, entry); + } + } + + private static CryptoProviderKeyDescriptor CreateDescriptor(string providerName, Pkcs11GostKeyEntry entry) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["subject"] = entry.Certificate.Subject, + ["issuer"] = entry.Certificate.Issuer, + ["thumbprint"] = entry.Certificate.Thumbprint, + ["library"] = entry.Session.LibraryPath, + ["slotId"] = entry.Session.SlotId, + ["tokenLabel"] = entry.Session.TokenLabel, + ["privateKeyLabel"] = entry.Session.PrivateKeyLabel, + ["publicKeyLabel"] = entry.Session.PublicKeyLabel, + ["mechanismId"] = $"0x{entry.SignMechanismId:X}", + ["bitStrength"] = entry.Is256 ? "256" : "512" + }; + + return new CryptoProviderKeyDescriptor(providerName, entry.KeyId, entry.AlgorithmId, metadata); + } +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderOptions.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderOptions.cs new file mode 100644 index 000000000..5a46b4a10 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostProviderOptions.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +/// +/// Configuration surface for the PKCS#11-based GOST provider. +/// +public sealed class Pkcs11GostProviderOptions +{ + private readonly IList keys = new List(); + + /// + /// Key descriptors managed by the provider. + /// + public IList Keys => keys; + + public Pkcs11GostProviderOptions Clone() + { + var clone = new Pkcs11GostProviderOptions(); + foreach (var key in keys) + { + clone.Keys.Add(key.Clone()); + } + + return clone; + } +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostSigner.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostSigner.cs new file mode 100644 index 000000000..8295afe67 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11GostSigner.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.Security; +using StellaOps.Cryptography; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +internal sealed class Pkcs11GostSigner : ICryptoSigner +{ + private static readonly string[] DefaultKeyOps = { "sign", "verify" }; + + private readonly Pkcs11GostKeyEntry entry; + + public Pkcs11GostSigner(Pkcs11GostKeyEntry entry) + { + this.entry = entry ?? throw new ArgumentNullException(nameof(entry)); + } + + public string KeyId => entry.KeyId; + + public string AlgorithmId => entry.AlgorithmId; + + public ValueTask SignAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var digest = GostDigestUtilities.ComputeDigest(data.Span, entry.Is256); + var signature = Pkcs11SignerUtilities.SignDigest(entry, digest); + return ValueTask.FromResult(signature); + } + + public ValueTask VerifyAsync( + ReadOnlyMemory data, + ReadOnlyMemory signature, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var digestSigner = new Gost3410DigestSigner( + new Gost3410Signer(), + GostDigestUtilities.CreateDigest(entry.Is256)); + digestSigner.Init(false, entry.PublicKeyParameters); + var buffer = data.ToArray(); + digestSigner.BlockUpdate(buffer, 0, buffer.Length); + var verified = digestSigner.VerifySignature(signature.ToArray()); + return ValueTask.FromResult(verified); + } + + public JsonWebKey ExportPublicJsonWebKey() + { + var jwk = new JsonWebKey + { + Kid = KeyId, + Alg = AlgorithmId, + Kty = "EC", + Crv = entry.Is256 ? "GOST3410-2012-256" : "GOST3410-2012-512", + Use = JsonWebKeyUseNames.Sig + }; + + foreach (var op in DefaultKeyOps) + { + jwk.KeyOps.Add(op); + } + + jwk.X5c.Add(Convert.ToBase64String(entry.Certificate.RawData)); + return jwk; + } +} + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11Mechanisms.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11Mechanisms.cs new file mode 100644 index 000000000..041bf2360 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11Mechanisms.cs @@ -0,0 +1,9 @@ +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +internal static class Pkcs11Mechanisms +{ + // Default values sourced from PKCS#11 v2.40 (TC26 extensions). Deployments can override via configuration. + public const uint DefaultGost12_256Signature = 0x00001255; + public const uint DefaultGost12_512Signature = 0x00001256; +} + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SessionOptions.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SessionOptions.cs new file mode 100644 index 000000000..3f1afb8f0 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SessionOptions.cs @@ -0,0 +1,19 @@ +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +internal sealed class Pkcs11SessionOptions +{ + public string LibraryPath { get; init; } = string.Empty; + + public string? SlotId { get; init; } + + public string? TokenLabel { get; init; } + + public string? UserPin { get; init; } + + public string? UserPinEnvironmentVariable { get; init; } + + public string? PrivateKeyLabel { get; init; } + + public string? PublicKeyLabel { get; init; } +} + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SignerUtilities.cs b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SignerUtilities.cs new file mode 100644 index 000000000..5ffef183c --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/Pkcs11SignerUtilities.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Net.Pkcs11Interop.Common; +using Net.Pkcs11Interop.HighLevelAPI; +using StellaOps.Cryptography; +using ISession = Net.Pkcs11Interop.HighLevelAPI.Session; + +namespace StellaOps.Cryptography.Plugin.Pkcs11Gost; + +internal static class Pkcs11SignerUtilities +{ + public static byte[] SignDigest(Pkcs11GostKeyEntry entry, ReadOnlySpan digest) + { + using var pkcs11 = new Pkcs11(entry.Session.LibraryPath, AppType.MultiThreaded); + var slot = ResolveSlot(pkcs11, entry.Session); + if (slot is null) + { + throw new InvalidOperationException("No PKCS#11 slot/token matched the provided configuration."); + } + + using var session = slot.OpenSession(SessionType.ReadWrite); + var loggedIn = false; + try + { + var pin = ResolvePin(entry.Session); + if (!string.IsNullOrWhiteSpace(pin)) + { + session.Login(CKU.CKU_USER, pin); + loggedIn = true; + } + + var privateHandle = FindObject(session, CKO.CKO_PRIVATE_KEY, entry.Session.PrivateKeyLabel); + if (privateHandle is null) + { + throw new InvalidOperationException($"Private key with label '{entry.Session.PrivateKeyLabel}' was not found."); + } + + var mechanism = new Mechanism(entry.SignMechanismId); + return session.Sign(mechanism, privateHandle, digest.ToArray()); + } + finally + { + if (loggedIn) + { + try { session.Logout(); } catch { /* ignored */ } + } + } + } + + private static Slot? ResolveSlot(Pkcs11 pkcs11, Pkcs11SessionOptions options) + { + var slots = pkcs11.GetSlotList(SlotsType.WithTokenPresent); + if (slots.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(options.SlotId)) + { + return slots.FirstOrDefault(slot => + string.Equals(slot.SlotId.ToString(), options.SlotId, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(options.TokenLabel)) + { + return slots.FirstOrDefault(slot => + { + var tokenInfo = slot.GetTokenInfo(); + return string.Equals(tokenInfo.Label?.Trim(), options.TokenLabel?.Trim(), StringComparison.OrdinalIgnoreCase); + }); + } + + return slots[0]; + } + + private static ObjectHandle? FindObject(ISession session, CKO objectClass, string? label) + { + var template = new List + { + new(CKA.CKA_CLASS, (uint)objectClass) + }; + + if (!string.IsNullOrWhiteSpace(label)) + { + template.Add(new ObjectAttribute(CKA.CKA_LABEL, label)); + } + + var handles = session.FindAllObjects(template); + return handles.FirstOrDefault(); + } + + private static string? ResolvePin(Pkcs11SessionOptions options) + { + if (!string.IsNullOrWhiteSpace(options.UserPin)) + { + return options.UserPin; + } + + if (!string.IsNullOrWhiteSpace(options.UserPinEnvironmentVariable)) + { + return Environment.GetEnvironmentVariable(options.UserPinEnvironmentVariable); + } + + return null; + } +} diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj new file mode 100644 index 000000000..45e15a9d0 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + preview + enable + enable + true + + + + + + + + + + + + + + + + diff --git a/src/__Libraries/StellaOps.Cryptography/CryptoHashFactory.cs b/src/__Libraries/StellaOps.Cryptography/CryptoHashFactory.cs new file mode 100644 index 000000000..59fdf51d7 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/CryptoHashFactory.cs @@ -0,0 +1,13 @@ +namespace StellaOps.Cryptography; + +/// +/// Factory helpers for creating crypto hash implementations outside of dependency injection scenarios. +/// +public static class CryptoHashFactory +{ + /// + /// Creates the default ICryptoHash implementation. + /// + public static ICryptoHash CreateDefault(CryptoHashOptions? options = null) + => new DefaultCryptoHash(options); +} diff --git a/src/__Libraries/StellaOps.Cryptography/CryptoHashOptions.cs b/src/__Libraries/StellaOps.Cryptography/CryptoHashOptions.cs new file mode 100644 index 000000000..a5839a36a --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/CryptoHashOptions.cs @@ -0,0 +1,6 @@ +namespace StellaOps.Cryptography; + +public sealed class CryptoHashOptions +{ + public string DefaultAlgorithm { get; set; } = HashAlgorithms.Sha256; +} diff --git a/src/__Libraries/StellaOps.Cryptography/CryptoProviderDiagnostics.cs b/src/__Libraries/StellaOps.Cryptography/CryptoProviderDiagnostics.cs new file mode 100644 index 000000000..0c820d321 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/CryptoProviderDiagnostics.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace StellaOps.Cryptography; + +/// +/// Describes key material surfaced by crypto providers for diagnostics. +/// +public sealed record CryptoProviderKeyDescriptor( + string Provider, + string KeyId, + string AlgorithmId, + IReadOnlyDictionary Metadata); + +/// +/// Optional interface for providers that can expose key metadata without revealing private material. +/// +public interface ICryptoProviderDiagnostics +{ + IEnumerable DescribeKeys(); +} + diff --git a/src/__Libraries/StellaOps.Cryptography/CryptoProviderMetrics.cs b/src/__Libraries/StellaOps.Cryptography/CryptoProviderMetrics.cs new file mode 100644 index 000000000..6a8390ba2 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/CryptoProviderMetrics.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Diagnostics.Metrics; + +namespace StellaOps.Cryptography; + +internal static class CryptoProviderMetrics +{ + private static readonly Meter Meter = new("stellaops.crypto", "1.0.0"); + private static readonly Counter ProviderResolutionCounter = + Meter.CreateCounter("crypto_provider_resolutions_total", description: "Count of successful provider resolutions."); + + private static readonly Counter ProviderResolutionFailureCounter = + Meter.CreateCounter("crypto_provider_resolution_failures_total", description: "Count of failed provider resolutions."); + + public static void RecordProviderResolution(string providerName, CryptoCapability capability, string algorithmId) + { + ProviderResolutionCounter.Add(1, + new KeyValuePair("provider", providerName), + new KeyValuePair("capability", capability.ToString()), + new KeyValuePair("algorithm", algorithmId)); + } + + public static void RecordProviderResolutionFailure(CryptoCapability capability, string algorithmId) + { + ProviderResolutionFailureCounter.Add(1, + new KeyValuePair("capability", capability.ToString()), + new KeyValuePair("algorithm", algorithmId)); + } +} + diff --git a/src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs b/src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs index cb6e2ffd6..cbbeea78d 100644 --- a/src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs +++ b/src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs @@ -64,10 +64,12 @@ public sealed class CryptoProviderRegistry : ICryptoProviderRegistry { if (provider.Supports(capability, algorithmId)) { + CryptoProviderMetrics.RecordProviderResolution(provider.Name, capability, algorithmId); return provider; } } + CryptoProviderMetrics.RecordProviderResolutionFailure(capability, algorithmId); throw new InvalidOperationException( $"No crypto provider is registered for capability '{capability}' and algorithm '{algorithmId}'."); } @@ -88,11 +90,13 @@ public sealed class CryptoProviderRegistry : ICryptoProviderRegistry } var signer = hinted.GetSigner(algorithmId, keyReference); + CryptoProviderMetrics.RecordProviderResolution(hinted.Name, capability, algorithmId); return new CryptoSignerResolution(signer, hinted.Name); } var provider = ResolveOrThrow(capability, algorithmId); var resolved = provider.GetSigner(algorithmId, keyReference); + CryptoProviderMetrics.RecordProviderResolution(provider.Name, capability, algorithmId); return new CryptoSignerResolution(resolved, provider.Name); } diff --git a/src/__Libraries/StellaOps.Cryptography/DefaultCryptoHash.cs b/src/__Libraries/StellaOps.Cryptography/DefaultCryptoHash.cs new file mode 100644 index 000000000..894851483 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/DefaultCryptoHash.cs @@ -0,0 +1,169 @@ +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Org.BouncyCastle.Crypto; + +namespace StellaOps.Cryptography; + +public sealed class DefaultCryptoHash : ICryptoHash +{ + private readonly IOptionsMonitor options; + private readonly ILogger logger; + + [ActivatorUtilitiesConstructor] + public DefaultCryptoHash( + IOptionsMonitor options, + ILogger? logger = null) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.logger = logger ?? NullLogger.Instance; + } + + internal DefaultCryptoHash(CryptoHashOptions? options = null) + : this(new StaticOptionsMonitor(options ?? new CryptoHashOptions()), NullLogger.Instance) + { + } + + public byte[] ComputeHash(ReadOnlySpan data, string? algorithmId = null) + { + var algorithm = NormalizeAlgorithm(algorithmId); + return algorithm switch + { + HashAlgorithms.Sha256 => ComputeSha256(data), + HashAlgorithms.Sha512 => ComputeSha512(data), + HashAlgorithms.Gost3411_2012_256 => GostDigestUtilities.ComputeDigest(data, use256: true), + HashAlgorithms.Gost3411_2012_512 => GostDigestUtilities.ComputeDigest(data, use256: false), + _ => throw new InvalidOperationException($"Unsupported hash algorithm {algorithm}.") + }; + } + + public string ComputeHashHex(ReadOnlySpan data, string? algorithmId = null) + => Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant(); + + public string ComputeHashBase64(ReadOnlySpan data, string? algorithmId = null) + => Convert.ToBase64String(ComputeHash(data, algorithmId)); + + public async ValueTask ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + cancellationToken.ThrowIfCancellationRequested(); + + var algorithm = NormalizeAlgorithm(algorithmId); + return algorithm switch + { + HashAlgorithms.Sha256 => await ComputeShaStreamAsync(HashAlgorithmName.SHA256, stream, cancellationToken).ConfigureAwait(false), + HashAlgorithms.Sha512 => await ComputeShaStreamAsync(HashAlgorithmName.SHA512, stream, cancellationToken).ConfigureAwait(false), + HashAlgorithms.Gost3411_2012_256 => await ComputeGostStreamAsync(use256: true, stream, cancellationToken).ConfigureAwait(false), + HashAlgorithms.Gost3411_2012_512 => await ComputeGostStreamAsync(use256: false, stream, cancellationToken).ConfigureAwait(false), + _ => throw new InvalidOperationException($"Unsupported hash algorithm {algorithm}.") + }; + } + + public async ValueTask ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) + { + var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static byte[] ComputeSha256(ReadOnlySpan data) + { + Span buffer = stackalloc byte[32]; + SHA256.HashData(data, buffer); + return buffer.ToArray(); + } + + private static byte[] ComputeSha512(ReadOnlySpan data) + { + Span buffer = stackalloc byte[64]; + SHA512.HashData(data, buffer); + return buffer.ToArray(); + } + + private static async ValueTask ComputeShaStreamAsync(HashAlgorithmName name, Stream stream, CancellationToken cancellationToken) + { + using var incremental = IncrementalHash.CreateHash(name); + var buffer = ArrayPool.Shared.Rent(128 * 1024); + try + { + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + incremental.AppendData(buffer, 0, bytesRead); + } + + return incremental.GetHashAndReset(); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static async ValueTask ComputeGostStreamAsync(bool use256, Stream stream, CancellationToken cancellationToken) + { + var digest = GostDigestUtilities.CreateDigest(use256); + var buffer = ArrayPool.Shared.Rent(128 * 1024); + try + { + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + digest.BlockUpdate(buffer, 0, bytesRead); + } + + var output = new byte[digest.GetDigestSize()]; + digest.DoFinal(output, 0); + return output; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private string NormalizeAlgorithm(string? algorithmId) + { + var defaultAlgorithm = options.CurrentValue?.DefaultAlgorithm; + if (!string.IsNullOrWhiteSpace(algorithmId)) + { + return algorithmId.Trim().ToUpperInvariant(); + } + + if (!string.IsNullOrWhiteSpace(defaultAlgorithm)) + { + return defaultAlgorithm.Trim().ToUpperInvariant(); + } + + return HashAlgorithms.Sha256; + } + + private sealed class StaticOptionsMonitor : IOptionsMonitor + { + private readonly CryptoHashOptions options; + + public StaticOptionsMonitor(CryptoHashOptions options) + => this.options = options; + + public CryptoHashOptions CurrentValue => options; + + public CryptoHashOptions Get(string? name) => options; + + public IDisposable OnChange(Action listener) + => NullDisposable.Instance; + + private sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + public void Dispose() + { + } + } + } +} diff --git a/src/__Libraries/StellaOps.Cryptography/DefaultCryptoProvider.cs b/src/__Libraries/StellaOps.Cryptography/DefaultCryptoProvider.cs index 5f2d0ac30..b25902edf 100644 --- a/src/__Libraries/StellaOps.Cryptography/DefaultCryptoProvider.cs +++ b/src/__Libraries/StellaOps.Cryptography/DefaultCryptoProvider.cs @@ -9,8 +9,8 @@ namespace StellaOps.Cryptography; /// /// Default in-process crypto provider exposing password hashing capabilities. /// -public sealed class DefaultCryptoProvider : ICryptoProvider -{ +public sealed class DefaultCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics +{ private readonly ConcurrentDictionary passwordHashers; private readonly ConcurrentDictionary signingKeys; private static readonly HashSet SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase) @@ -105,8 +105,38 @@ public sealed class DefaultCryptoProvider : ICryptoProvider return signingKeys.TryRemove(keyId, out _); } - public IReadOnlyCollection GetSigningKeys() - => signingKeys.Values.ToArray(); + public IReadOnlyCollection GetSigningKeys() + => signingKeys.Values.ToArray(); + + public IEnumerable DescribeKeys() + { + foreach (var key in signingKeys.Values) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["kind"] = key.Kind.ToString(), + ["createdAt"] = key.CreatedAt.UtcDateTime.ToString("O"), + ["providerHint"] = key.Reference.ProviderHint, + ["provider"] = Name + }; + + if (key.ExpiresAt.HasValue) + { + metadata["expiresAt"] = key.ExpiresAt.Value.UtcDateTime.ToString("O"); + } + + foreach (var pair in key.Metadata) + { + metadata[$"meta.{pair.Key}"] = pair.Value; + } + + yield return new CryptoProviderKeyDescriptor( + Name, + key.Reference.KeyId, + key.AlgorithmId, + metadata); + } + } private static void EnsureSigningSupported(string algorithmId) { diff --git a/src/__Libraries/StellaOps.Cryptography/GostDigestUtilities.cs b/src/__Libraries/StellaOps.Cryptography/GostDigestUtilities.cs new file mode 100644 index 000000000..cfc69b96f --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/GostDigestUtilities.cs @@ -0,0 +1,24 @@ +using System; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; + +namespace StellaOps.Cryptography; + +public static class GostDigestUtilities +{ + public static byte[] ComputeDigest(ReadOnlySpan data, bool use256) + { + IDigest digest = CreateDigestInstance(use256); + var buffer = data.ToArray(); + digest.BlockUpdate(buffer, 0, buffer.Length); + var output = new byte[digest.GetDigestSize()]; + digest.DoFinal(output, 0); + return output; + } + + public static IDigest CreateDigest(bool use256) + => CreateDigestInstance(use256); + + private static IDigest CreateDigestInstance(bool use256) + => use256 ? new Gost3411_2012_256Digest() : new Gost3411_2012_512Digest(); +} diff --git a/src/__Libraries/StellaOps.Cryptography/HashAlgorithms.cs b/src/__Libraries/StellaOps.Cryptography/HashAlgorithms.cs new file mode 100644 index 000000000..58b2a4652 --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/HashAlgorithms.cs @@ -0,0 +1,12 @@ +namespace StellaOps.Cryptography; + +/// +/// Well-known digest algorithm identifiers supported by . +/// +public static class HashAlgorithms +{ + public const string Sha256 = "SHA256"; + public const string Sha512 = "SHA512"; + public const string Gost3411_2012_256 = "GOST3411-2012-256"; + public const string Gost3411_2012_512 = "GOST3411-2012-512"; +} diff --git a/src/__Libraries/StellaOps.Cryptography/ICryptoHash.cs b/src/__Libraries/StellaOps.Cryptography/ICryptoHash.cs new file mode 100644 index 000000000..6be087eae --- /dev/null +++ b/src/__Libraries/StellaOps.Cryptography/ICryptoHash.cs @@ -0,0 +1,19 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Cryptography; + +public interface ICryptoHash +{ + byte[] ComputeHash(ReadOnlySpan data, string? algorithmId = null); + + string ComputeHashHex(ReadOnlySpan data, string? algorithmId = null); + + string ComputeHashBase64(ReadOnlySpan data, string? algorithmId = null); + + ValueTask ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default); + + ValueTask ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default); +} diff --git a/src/__Libraries/StellaOps.Cryptography/SignatureAlgorithms.cs b/src/__Libraries/StellaOps.Cryptography/SignatureAlgorithms.cs index 6667209c3..160195252 100644 --- a/src/__Libraries/StellaOps.Cryptography/SignatureAlgorithms.cs +++ b/src/__Libraries/StellaOps.Cryptography/SignatureAlgorithms.cs @@ -1,13 +1,16 @@ -namespace StellaOps.Cryptography; - -/// -/// Known signature algorithm identifiers. -/// -public static class SignatureAlgorithms -{ - public const string Es256 = "ES256"; - public const string Es384 = "ES384"; - public const string Es512 = "ES512"; - public const string Ed25519 = "ED25519"; - public const string EdDsa = "EdDSA"; -} +namespace StellaOps.Cryptography; + +/// +/// Known signature algorithm identifiers. +/// +public static class SignatureAlgorithms +{ + public const string Es256 = "ES256"; + public const string Es384 = "ES384"; + public const string Es512 = "ES512"; + public const string Ed25519 = "ED25519"; + public const string EdDsa = "EdDSA"; + public const string GostR3410_2012_256 = "GOST12-256"; + public const string GostR3410_2012_512 = "GOST12-512"; +} + diff --git a/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj b/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj index c884dca0b..acbcc3394 100644 --- a/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj +++ b/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj @@ -9,8 +9,10 @@ $(DefineConstants);STELLAOPS_CRYPTO_SODIUM - - - - - + + + + + + + diff --git a/src/__Libraries/StellaOps.Cryptography/TASKS.md b/src/__Libraries/StellaOps.Cryptography/TASKS.md index d14932d43..bcceb5ee4 100644 --- a/src/__Libraries/StellaOps.Cryptography/TASKS.md +++ b/src/__Libraries/StellaOps.Cryptography/TASKS.md @@ -2,6 +2,14 @@ | ID | Status | Owner | Description | Dependencies | Exit Criteria | |----|--------|-------|-------------|--------------|---------------| +| SEC-CRYPTO-90-001 | DONE (2025-11-07) | Security Guild | Produce RootPack_RU sovereign crypto implementation plan, identify provider strategy (CryptoPro + PKCS#11), and slot work into Sprint 190 with task breakdown. | None | Plan captured in `SPRINT_190_ops_offline.md` + this board; risks/assumptions logged. | +| SEC-CRYPTO-90-002 | DONE (2025-11-07) | Security Guild | Extend signature/catalog constants and configuration schema to recognize `GOST12-256/512`, regional crypto profiles, and provider preference ordering. | SEC-CRYPTO-90-001 | New alg IDs wired, configs validated, docs updated. | +| SEC-CRYPTO-90-003 | DONE (2025-11-07) | Security Guild | Implement `StellaOps.Cryptography.Plugin.CryptoPro` provider (sign/verify/JWK export) using CryptoPro CSP/GostCryptography with deterministic logging + tests. | SEC-CRYPTO-90-002 | Provider registered, unit/integration tests (skippable if CSP absent) passing. | +| SEC-CRYPTO-90-004 | DONE (2025-11-07) | Security Guild | Implement `StellaOps.Cryptography.Plugin.Pkcs11Gost` provider (Rutoken/JaCarta) via Pkcs11Interop, configurable slot/pin/module management, and disposal safeguards. | SEC-CRYPTO-90-002 | Provider registered, token smoke tests + error handling documented. | +| SEC-CRYPTO-90-005 | DONE (2025-11-08) | Security Guild | Add configuration-driven provider selection (`crypto.regionalProfiles`), CLI/diagnostic verb to list providers/keys, and deterministic telemetry for usage. | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | CLI lists providers, configs switch ordering without code changes, telemetry events emitted. | +| SEC-CRYPTO-90-006 | DONE (2025-11-08) | Security Guild | Build deterministic test harness (Streebog + signature vectors), manual runbooks for hardware validation, and capture RootPack audit metadata. | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | `scripts/crypto/run-rootpack-ru-tests.sh` emits deterministic logs/TRX; validation runbook updated with harness + hardware guidance; audit metadata artifacts enumerated. | +| SEC-CRYPTO-90-007 | DONE (2025-11-08) | Security Guild | Package RootPack_RU artifacts (plugin binaries, config templates, trust anchors) and document deployment/install steps + compliance evidence. | SEC-CRYPTO-90-005, SEC-CRYPTO-90-006 | `scripts/crypto/package-rootpack-ru.sh` builds bundle with docs/config/trust anchors; `rootpack_ru_package.md` guides ops/air-gap workflows. | +| SEC-CRYPTO-90-008 | DONE (2025-11-08) | Security Guild | Audit repository for any cryptography usage bypassing `StellaOps.Cryptography` (direct libsodium/BouncyCastle callers, TLS custom code) and file remediation tasks to route via providers. | SEC-CRYPTO-90-002 | Audit report updated with remediation IDs; module TASKS boards now include `*-CRYPTO-90-001` follow-ups; backlog ready for implementation. | > Remark (2025-10-14): Cleanup service wired to store; background sweep + invite audit tests added. > Remark (2025-10-14): Token usage metadata persisted with replay audits + handler/unit coverage. > Remark (2025-10-14): Analyzer surfaces warnings during CLI load; docs updated with mitigation steps. diff --git a/src/__Libraries/StellaOps.Ingestion.Telemetry/IngestionTelemetry.cs b/src/__Libraries/StellaOps.Ingestion.Telemetry/IngestionTelemetry.cs new file mode 100644 index 000000000..fa921dd54 --- /dev/null +++ b/src/__Libraries/StellaOps.Ingestion.Telemetry/IngestionTelemetry.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace StellaOps.Ingestion.Telemetry; + +public static class IngestionTelemetry +{ + public const string ActivitySourceName = "StellaOps.Ingestion"; + public const string MeterName = "StellaOps.Ingestion"; + + public const string PhaseFetch = "fetch"; + public const string PhaseTransform = "transform"; + public const string PhaseWrite = "write"; + + public const string ResultOk = "ok"; + public const string ResultReject = "reject"; + public const string ResultNoop = "noop"; + + private const string WriteMetricName = "ingestion_write_total"; + private const string ViolationMetricName = "aoc_violation_total"; + private const string LatencyMetricName = "ingestion_latency_seconds"; + + private static readonly ActivitySource ActivitySource = new(ActivitySourceName); + private static readonly Meter Meter = new(MeterName); + + private static readonly Counter WriteCounter = Meter.CreateCounter( + WriteMetricName, + unit: "count", + description: "Counts raw advisory ingestion attempts grouped by tenant, source, and outcome."); + + private static readonly Counter ViolationCounter = Meter.CreateCounter( + ViolationMetricName, + unit: "count", + description: "Counts Aggregation-Only Contract violations raised during ingestion."); + + private static readonly Histogram LatencyHistogram = Meter.CreateHistogram( + LatencyMetricName, + unit: "s", + description: "Ingestion stage latency measured in seconds."); + + public static Activity? StartFetchActivity( + string tenant, + string source, + string? upstreamId, + string? contentHash, + string? uri = null) + => StartActivity("ingest.fetch", tenant, source, upstreamId, contentHash, builder: activity => + { + if (!string.IsNullOrWhiteSpace(uri)) + { + activity.SetTag("uri", uri); + } + }); + + public static Activity? StartTransformActivity( + string tenant, + string source, + string? upstreamId, + string? contentHash, + string? documentType = null, + long? payloadBytes = null) + => StartActivity("ingest.transform", tenant, source, upstreamId, contentHash, builder: activity => + { + if (!string.IsNullOrWhiteSpace(documentType)) + { + activity.SetTag("documentType", documentType); + } + + if (payloadBytes.HasValue && payloadBytes.Value >= 0) + { + activity.SetTag("payloadBytes", payloadBytes.Value); + } + }); + + public static Activity? StartWriteActivity( + string tenant, + string source, + string? upstreamId, + string? contentHash, + string collection) + => StartActivity("ingest.write", tenant, source, upstreamId, contentHash, builder: activity => + { + activity.SetTag("collection", collection); + }); + + public static Activity? StartGuardActivity( + string tenant, + string source, + string? upstreamId, + string? contentHash, + string? supersedes) + => StartActivity("aoc.guard", tenant, source, upstreamId, contentHash, builder: activity => + { + if (!string.IsNullOrWhiteSpace(supersedes)) + { + activity.SetTag("supersedes", supersedes); + } + }); + + public static void RecordWriteAttempt(string tenant, string source, string result) + { + if (string.IsNullOrWhiteSpace(tenant) || string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(result)) + { + return; + } + + var tags = new TagList + { + { "tenant", tenant }, + { "source", source }, + { "result", result } + }; + + WriteCounter.Add(1, tags); + } + + public static void RecordViolation(string tenant, string source, string code) + { + if (string.IsNullOrWhiteSpace(tenant) || string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(code)) + { + return; + } + + var tags = new TagList + { + { "tenant", tenant }, + { "source", source }, + { "code", code } + }; + + ViolationCounter.Add(1, tags); + } + + public static void RecordLatency(string tenant, string source, string phase, TimeSpan duration) + { + if (string.IsNullOrWhiteSpace(tenant) || string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(phase)) + { + return; + } + + var tags = new TagList + { + { "tenant", tenant }, + { "source", source }, + { "phase", phase } + }; + + var seconds = duration.TotalSeconds; + if (double.IsNaN(seconds) || double.IsInfinity(seconds)) + { + seconds = 0d; + } + + if (seconds < 0) + { + seconds = 0d; + } + + LatencyHistogram.Record(seconds, tags); + } + + private static Activity? StartActivity( + string name, + string tenant, + string source, + string? upstreamId, + string? contentHash, + Action? builder = null) + { + var activity = ActivitySource.StartActivity(name, ActivityKind.Internal); + if (activity is null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(tenant)) + { + activity.SetTag("tenant", tenant); + } + + if (!string.IsNullOrWhiteSpace(source)) + { + activity.SetTag("source", source); + } + + if (!string.IsNullOrWhiteSpace(upstreamId)) + { + activity.SetTag("upstream.id", upstreamId); + } + + if (!string.IsNullOrWhiteSpace(contentHash)) + { + activity.SetTag("contentHash", contentHash); + } + + builder?.Invoke(activity); + return activity; + } +} diff --git a/src/__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj b/src/__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj new file mode 100644 index 000000000..f89b309aa --- /dev/null +++ b/src/__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + enable + enable + + diff --git a/src/__Libraries/StellaOps.Plugin/TASKS.md b/src/__Libraries/StellaOps.Plugin/TASKS.md index 2265b2fcd..1e893aa7b 100644 --- a/src/__Libraries/StellaOps.Plugin/TASKS.md +++ b/src/__Libraries/StellaOps.Plugin/TASKS.md @@ -1,3 +1,4 @@ # TASKS | Task | Owner(s) | Depends on | Notes | |---|---|---|---| +| PLUGIN-DI-08-001 | DONE (2025-10-21) | Plugin Platform Guild | — | Scoped service support in plugin bootstrap – ensure `[ServiceBinding]` metadata flows through hosts deterministically, add dynamic plugin tests, and document the attribute usage. | diff --git a/src/__Libraries/StellaOps.Replay.Core/ReplayManifest.cs b/src/__Libraries/StellaOps.Replay.Core/ReplayManifest.cs new file mode 100644 index 000000000..54ba41139 --- /dev/null +++ b/src/__Libraries/StellaOps.Replay.Core/ReplayManifest.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StellaOps.Replay.Core; + +public sealed class ReplayManifest +{ + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; set; } = "1.0"; + + [JsonPropertyName("scan")] + public ReplayScanMetadata Scan { get; set; } = new(); + + [JsonPropertyName("reachability")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ReplayReachabilitySection? Reachability { get; set; } +} + +public sealed class ReplayScanMetadata +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("time")] + public DateTimeOffset Time { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class ReplayReachabilitySection +{ + [JsonPropertyName("graphs")] + public List Graphs { get; set; } = new(); + + [JsonPropertyName("runtimeTraces")] + public List RuntimeTraces { get; set; } = new(); +} + +public sealed class ReplayReachabilityGraphReference +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = "static"; + + [JsonPropertyName("casUri")] + public string CasUri { get; set; } = string.Empty; + + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + [JsonPropertyName("analyzer")] + public string Analyzer { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; +} + +public sealed class ReplayReachabilityTraceReference +{ + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + [JsonPropertyName("casUri")] + public string CasUri { get; set; } = string.Empty; + + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + [JsonPropertyName("recordedAt")] + public DateTimeOffset RecordedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/__Libraries/StellaOps.Replay.Core/ReplayManifestExtensions.cs b/src/__Libraries/StellaOps.Replay.Core/ReplayManifestExtensions.cs new file mode 100644 index 000000000..25f50af9c --- /dev/null +++ b/src/__Libraries/StellaOps.Replay.Core/ReplayManifestExtensions.cs @@ -0,0 +1,22 @@ +using System; + +namespace StellaOps.Replay.Core; + +public static class ReplayManifestExtensions +{ + public static void AddReachabilityGraph(this ReplayManifest manifest, ReplayReachabilityGraphReference graph) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(graph); + manifest.Reachability ??= new ReplayReachabilitySection(); + manifest.Reachability.Graphs.Add(graph); + } + + public static void AddReachabilityTrace(this ReplayManifest manifest, ReplayReachabilityTraceReference trace) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(trace); + manifest.Reachability ??= new ReplayReachabilitySection(); + manifest.Reachability.RuntimeTraces.Add(trace); + } +} diff --git a/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj b/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj new file mode 100644 index 000000000..1578f5209 --- /dev/null +++ b/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + + + + + diff --git a/src/__Libraries/StellaOps.Replay.Core/TASKS.md b/src/__Libraries/StellaOps.Replay.Core/TASKS.md index bfd187bb2..8c8eac893 100644 --- a/src/__Libraries/StellaOps.Replay.Core/TASKS.md +++ b/src/__Libraries/StellaOps.Replay.Core/TASKS.md @@ -4,3 +4,4 @@ |----|--------|-------------|--------------|---------------| | REPLAY-CORE-185-001 | TODO | Scaffold replay core library (`StellaOps.Replay.Core`) with manifest schema types, canonical JSON utilities, Merkle helpers, DSSE payload builders, and module charter updates referencing `docs/replay/DETERMINISTIC_REPLAY.md`. | Sprint 185 replay planning | Library builds/tests succeed; AGENTS.md updated; integration notes cross-linked. | | REPLAY-CORE-185-002 | TODO | Implement deterministic bundle writer (tar.zst, CAS naming) and hashing abstractions; extend `docs/modules/platform/architecture-overview.md` with “Replay CAS” section. | REPLAY-CORE-185-001 | Bundle writer unit tests pass; documentation merged with examples; CAS layout reproducible. | +| REPLAY-REACH-201-005 | DOING (2025-11-08) | Extend manifest schema + bundle writer to include reachability graphs, runtime traces, analyzer versions, and CAS URIs; update docs + serializers per `SPRINT_201_reachability_explainability`. | REPLAY-CORE-185-001, SIGNALS-REACH-201-003 | Manifest schema merged; unit tests cover new sections; docs + CAS layout references updated. | diff --git a/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/CryptoProviderRegistryTests.cs b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/CryptoProviderRegistryTests.cs index daef167c0..9fc162678 100644 --- a/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/CryptoProviderRegistryTests.cs +++ b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/CryptoProviderRegistryTests.cs @@ -59,6 +59,22 @@ public class CryptoProviderRegistryTests Assert.Equal("key-a", fallbackResolution.Signer.KeyId); } + [Fact] + public void RegistryOptions_UsesActiveProfileOrder() + { + var options = new StellaOps.Cryptography.DependencyInjection.CryptoProviderRegistryOptions(); + options.PreferredProviders.Add("default"); + options.ActiveProfile = "ru-offline"; + options.Profiles["ru-offline"] = new StellaOps.Cryptography.DependencyInjection.CryptoProviderProfileOptions + { + PreferredProviders = { "ru.cryptopro.csp", "ru.pkcs11" } + }; + + var resolved = options.ResolvePreferredProviders(); + + Assert.Equal(new[] { "ru.cryptopro.csp", "ru.pkcs11" }, resolved); + } + private sealed class FakeCryptoProvider : ICryptoProvider { private readonly Dictionary signers = new(StringComparer.Ordinal); diff --git a/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/DefaultCryptoHashTests.cs b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/DefaultCryptoHashTests.cs new file mode 100644 index 000000000..1a7598196 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/DefaultCryptoHashTests.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using StellaOps.Cryptography; +using Xunit; + +namespace StellaOps.Cryptography.Tests; + +public sealed class DefaultCryptoHashTests +{ + private static readonly byte[] Sample = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog"); + + [Fact] + public void ComputeHash_Sha256_MatchesBcl() + { + var hash = CryptoHashFactory.CreateDefault(); + var expected = SHA256.HashData(Sample); + var actual = hash.ComputeHash(Sample, HashAlgorithms.Sha256); + Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant()); + } + + [Fact] + public void ComputeHash_Sha512_MatchesBcl() + { + var hash = CryptoHashFactory.CreateDefault(); + var expected = SHA512.HashData(Sample); + var actual = hash.ComputeHash(Sample, HashAlgorithms.Sha512); + Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant()); + } + + [Fact] + public void ComputeHash_Gost256_MatchesBouncyCastle() + { + var hash = CryptoHashFactory.CreateDefault(); + var expected = ComputeGostDigest(use256: true); + var actual = hash.ComputeHash(Sample, HashAlgorithms.Gost3411_2012_256); + Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant()); + } + + [Fact] + public void ComputeHash_Gost512_MatchesBouncyCastle() + { + var hash = CryptoHashFactory.CreateDefault(); + var expected = ComputeGostDigest(use256: false); + var actual = hash.ComputeHash(Sample, HashAlgorithms.Gost3411_2012_512); + Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant()); + } + + [Fact] + public async Task ComputeHashAsync_Stream_MatchesBuffer() + { + var hash = CryptoHashFactory.CreateDefault(); + await using var stream = new MemoryStream(Sample); + var streamDigest = await hash.ComputeHashAsync(stream, HashAlgorithms.Sha256); + var bufferDigest = hash.ComputeHash(Sample, HashAlgorithms.Sha256); + Assert.Equal(Convert.ToHexString(bufferDigest), Convert.ToHexString(streamDigest)); + } + + private static byte[] ComputeGostDigest(bool use256) + { + Org.BouncyCastle.Crypto.IDigest digest = use256 + ? new Gost3411_2012_256Digest() + : new Gost3411_2012_512Digest(); + digest.BlockUpdate(Sample, 0, Sample.Length); + var output = new byte[digest.GetDigestSize()]; + digest.DoFinal(output, 0); + return output; + } +} diff --git a/tests/reachability/README.md b/tests/reachability/README.md new file mode 100644 index 000000000..5742fe19d --- /dev/null +++ b/tests/reachability/README.md @@ -0,0 +1,15 @@ +# Reachability Fixture Harness + +This directory carries the reachbench fixture packs used by Sprint 201 to validate reachability explainability. + +- `fixtures/reachbench-2025-expanded/` contains 24 multi-language cases with reachable and unreachable variants, SBOMs, callgraphs, runtime traces, and DSSE envelopes. +- `StellaOps.Reachability.FixtureTests` provides lightweight guard rails that ensure each case keeps the expected files, JSON schemas, and ground-truth metadata before the Signals/Scanner reachability pipeline consumes them. + +## Running the fixture tests + +```bash +# From the repo root +DOTNET_CLI_UI_LANGUAGE=en dotnet test tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj +``` + +The tests simply validate the fixtures today; once the reachability engine lands they become the seed harness to replay reachable vs. unreachable scans deterministically. diff --git a/tests/reachability/StellaOps.Reachability.FixtureTests/ReachabilityReplayWriterTests.cs b/tests/reachability/StellaOps.Reachability.FixtureTests/ReachabilityReplayWriterTests.cs new file mode 100644 index 000000000..f2247c7ca --- /dev/null +++ b/tests/reachability/StellaOps.Reachability.FixtureTests/ReachabilityReplayWriterTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using FluentAssertions; +using StellaOps.Replay.Core; +using StellaOps.Scanner.Reachability; +using Xunit; + +namespace StellaOps.Reachability.FixtureTests; + +public sealed class ReachabilityReplayWriterTests +{ + [Fact] + public void AttachEvidence_AppendsGraphsAndTracesDeterministically() + { + var manifest = new ReplayManifest + { + Scan = new ReplayScanMetadata { Id = "scan-123", Time = DateTimeOffset.Parse("2025-10-15T10:00:00Z", CultureInfo.InvariantCulture) } + }; + + var graphs = new List + { + new("static", "cas://graph/B", "ABCDEF", "scanner-jvm", "1.0.0"), + new("framework", "cas://graph/A", "abcdef", "scanner-jvm", "1.0.0"), + new("static", "cas://graph/B", "ABCDEF", "scanner-jvm", "1.0.0") // duplicate + }; + + var traces = new List + { + new("zastava", "cas://trace/1", "FFEE", DateTimeOffset.Parse("2025-10-15T09:00:00+02:00", CultureInfo.InvariantCulture)), + new("zastava", "cas://trace/2", "ffee", DateTimeOffset.Parse("2025-10-15T09:05:00Z", CultureInfo.InvariantCulture)), + new("zastava", "cas://trace/1", "FFEE", DateTimeOffset.Parse("2025-10-15T09:00:00Z", CultureInfo.InvariantCulture)) // duplicate once normalized + }; + + var writer = new ReachabilityReplayWriter(); + writer.AttachEvidence(manifest, graphs, traces); + + manifest.Reachability.Should().NotBeNull(); + manifest.Reachability!.Graphs.Should().HaveCount(2); + manifest.Reachability.Graphs[0].CasUri.Should().Be("cas://graph/A"); + manifest.Reachability.Graphs[0].Sha256.Should().Be("abcdef"); + manifest.Reachability.Graphs[1].CasUri.Should().Be("cas://graph/B"); + manifest.Reachability.Graphs[1].Kind.Should().Be("static"); + + manifest.Reachability.RuntimeTraces.Should().HaveCount(2); + manifest.Reachability.RuntimeTraces[0].RecordedAt.Should().Be(DateTimeOffset.Parse("2025-10-15T07:00:00Z")); + manifest.Reachability.RuntimeTraces[0].Sha256.Should().Be("ffee"); + manifest.Reachability.RuntimeTraces[1].CasUri.Should().Be("cas://trace/2"); + } + + [Fact] + public void AttachEvidence_DoesNotCreateSectionWhenEmpty() + { + var manifest = new ReplayManifest(); + var writer = new ReachabilityReplayWriter(); + + writer.AttachEvidence(manifest, Array.Empty(), Array.Empty()); + + manifest.Reachability.Should().BeNull(); + } +} diff --git a/tests/reachability/StellaOps.Reachability.FixtureTests/ReachbenchFixtureTests.cs b/tests/reachability/StellaOps.Reachability.FixtureTests/ReachbenchFixtureTests.cs new file mode 100644 index 000000000..82d78a1ee --- /dev/null +++ b/tests/reachability/StellaOps.Reachability.FixtureTests/ReachbenchFixtureTests.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace StellaOps.Reachability.FixtureTests; + +public class ReachbenchFixtureTests +{ + private static readonly string RepoRoot = LocateRepoRoot(); + private static readonly string FixtureRoot = Path.Combine( + RepoRoot, "tests", "reachability", "fixtures", "reachbench-2025-expanded"); + private static readonly string CasesRoot = Path.Combine(FixtureRoot, "cases"); + + [Fact] + public void IndexListsAllCases() + { + Directory.Exists(FixtureRoot).Should().BeTrue("reachbench fixtures should exist under tests/reachability/fixtures"); + File.Exists(Path.Combine(FixtureRoot, "INDEX.json")).Should().BeTrue("the reachbench index must be present"); + + using var indexStream = File.OpenRead(Path.Combine(FixtureRoot, "INDEX.json")); + using var document = JsonDocument.Parse(indexStream); + var names = new List(); + var found = false; + JsonElement casesElement = default; + foreach (var property in document.RootElement.EnumerateObject()) + { + names.Add(property.Name); + if (property.NameEquals("cases")) + { + casesElement = property.Value; + found = true; + } + } + + found.Should().BeTrue($"INDEX.json should contain 'cases'. Properties present: {string.Join(",", names)}"); + casesElement.ValueKind.Should().Be(JsonValueKind.Array); + casesElement.GetArrayLength().Should().BeGreaterOrEqualTo(20, "expanded pack should carry broad coverage"); + + foreach (var entry in casesElement.EnumerateArray()) + { + var id = entry.GetProperty("id").GetString(); + id.Should().NotBeNullOrEmpty(); + var rel = entry.TryGetProperty("path", out var relProp) + ? relProp.GetString() + : Path.Combine("cases", id!); + rel.Should().NotBeNullOrEmpty(); + var path = Path.Combine(FixtureRoot, rel!); + Directory.Exists(path).Should().BeTrue($"case '{id}' folder '{rel}' should exist"); + } + } + + public static IEnumerable CaseVariantData() + { + foreach (var caseDir in Directory.EnumerateDirectories(CasesRoot)) + { + var caseId = Path.GetFileName(caseDir); + yield return new object[] { caseId!, Path.Combine(caseDir, "images", "reachable") }; + yield return new object[] { caseId!, Path.Combine(caseDir, "images", "unreachable") }; + } + } + + [Theory] + [MemberData(nameof(CaseVariantData))] + public void CaseVariantContainsExpectedArtifacts(string caseId, string variantPath) + { + Directory.Exists(variantPath).Should().BeTrue(); + + var requiredFiles = new[] + { + "manifest.json", + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json", + "attestation.dsse.json" + }; + + foreach (var file in requiredFiles) + { + File.Exists(Path.Combine(variantPath, file)).Should().BeTrue($"{caseId}:{Path.GetFileName(variantPath)} missing {file}"); + } + + var truthPath = Path.Combine(variantPath, "reachgraph.truth.json"); + using var truthStream = File.OpenRead(truthPath); + using var truthDoc = JsonDocument.Parse(truthStream); + truthDoc.RootElement.GetProperty("schema_version").GetString().Should().NotBeNullOrEmpty(); + truthDoc.RootElement.GetProperty("paths").ValueKind.Should().Be(JsonValueKind.Array); + } + + [Theory] + [MemberData(nameof(CaseVariantData))] + public void CaseGroundTruthMatchesVariants(string caseId, string variantPath) + { + var caseJsonPath = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(variantPath))!, "case.json"); + File.Exists(caseJsonPath).Should().BeTrue(); + + using var caseStream = File.OpenRead(caseJsonPath); + using var caseDoc = JsonDocument.Parse(caseStream); + var groundTruth = caseDoc.RootElement.GetProperty("ground_truth"); + var variantKey = variantPath.EndsWith("reachable", StringComparison.OrdinalIgnoreCase) + ? "reachable_variant" + : "unreachable_variant"; + + var variant = groundTruth.GetProperty(variantKey); + variant.GetProperty("status").GetString().Should().NotBeNullOrEmpty($"{caseId}:{variantKey} should set status"); + variant.TryGetProperty("evidence", out var evidence).Should().BeTrue($"{caseId}:{variantKey} should define evidence"); + evidence.TryGetProperty("paths", out var pathsProp).Should().BeTrue(); + pathsProp.ValueKind.Should().Be(JsonValueKind.Array); + } + + private static string LocateRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "Directory.Build.props"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Cannot locate repository root (missing Directory.Build.props)."); + } +} diff --git a/tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj b/tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj new file mode 100644 index 000000000..fbdee3df2 --- /dev/null +++ b/tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj @@ -0,0 +1,29 @@ + + + net10.0 + enable + enable + preview + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + fixtures\\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + + + + + diff --git a/tests/reachability/StellaOps.Replay.Core.Tests/ReplayManifestExtensionsTests.cs b/tests/reachability/StellaOps.Replay.Core.Tests/ReplayManifestExtensionsTests.cs new file mode 100644 index 000000000..cd0033b74 --- /dev/null +++ b/tests/reachability/StellaOps.Replay.Core.Tests/ReplayManifestExtensionsTests.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using FluentAssertions; +using StellaOps.Replay.Core; +using Xunit; + +namespace StellaOps.Replay.Core.Tests; + +public sealed class ReplayManifestExtensionsTests +{ + [Fact] + public void AddsReachabilityEvidence() + { + var manifest = new ReplayManifest + { + Scan = new ReplayScanMetadata { Id = "scan-1" } + }; + + manifest.AddReachabilityGraph(new ReplayReachabilityGraphReference + { + Kind = "static", + Analyzer = "scanner/java", + CasUri = "cas://replay/graph", + Sha256 = "abc", + Version = "1.0" + }); + + manifest.AddReachabilityTrace(new ReplayReachabilityTraceReference + { + Source = "zastava", + CasUri = "cas://replay/trace", + Sha256 = "def" + }); + + manifest.Reachability.Should().NotBeNull(); + manifest.Reachability!.Graphs.Should().HaveCount(1); + manifest.Reachability.RuntimeTraces.Should().HaveCount(1); + + var json = JsonSerializer.Serialize(manifest); + json.Should().Contain("\"reachability\""); + } +} diff --git a/tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj b/tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj new file mode 100644 index 000000000..e6318bd73 --- /dev/null +++ b/tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + preview + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/reachability/StellaOps.ScannerSignals.IntegrationTests/ScannerToSignalsReachabilityTests.cs b/tests/reachability/StellaOps.ScannerSignals.IntegrationTests/ScannerToSignalsReachabilityTests.cs new file mode 100644 index 000000000..375132bf9 --- /dev/null +++ b/tests/reachability/StellaOps.ScannerSignals.IntegrationTests/ScannerToSignalsReachabilityTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using StellaOps.Scanner.Reachability; +using StellaOps.Signals.Models; +using StellaOps.Signals.Options; +using StellaOps.Signals.Parsing; +using StellaOps.Signals.Persistence; +using StellaOps.Signals.Services; +using StellaOps.Signals.Storage; +using StellaOps.Signals.Storage.Models; +using Xunit; + +namespace StellaOps.ScannerSignals.IntegrationTests; + +public sealed class ScannerToSignalsReachabilityTests +{ + private static readonly string RepoRoot = LocateRepoRoot(); + private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "reachbench-2025-expanded", "cases"); + + [Fact] + public async Task ScannerBuilderFeedsSignalsScoringPipeline() + { + var caseId = "java-log4j-CVE-2021-44228-log4shell"; + var variant = "reachable"; + var variantPath = Path.Combine(FixtureRoot, caseId, "images", variant); + Directory.Exists(variantPath).Should().BeTrue(); + + var builder = ReachabilityGraphBuilder.FromFixture(variantPath); + var artifactJson = builder.BuildJson(indented: false); + var parser = new SimpleJsonCallgraphParser("java"); + var parserResolver = new StaticParserResolver(new Dictionary + { + ["java"] = parser + }); + var artifactStore = new InMemoryCallgraphArtifactStore(); + var callgraphRepo = new InMemoryCallgraphRepository(); + var ingestionService = new CallgraphIngestionService( + parserResolver, + artifactStore, + callgraphRepo, + Options.Create(new SignalsOptions()), + TimeProvider.System, + NullLogger.Instance); + + var request = new CallgraphIngestRequest( + Language: "java", + Component: caseId, + Version: variant, + ArtifactContentType: "application/json", + ArtifactFileName: "callgraph.static.json", + ArtifactContentBase64: Convert.ToBase64String(Encoding.UTF8.GetBytes(artifactJson)), + Metadata: null); + + var ingestResponse = await ingestionService.IngestAsync(request, CancellationToken.None); + ingestResponse.CallgraphId.Should().NotBeNullOrWhiteSpace(); + + var scoringService = new ReachabilityScoringService( + callgraphRepo, + new InMemoryReachabilityFactRepository(), + TimeProvider.System, + NullLogger.Instance); + + var truth = JsonDocument.Parse(File.ReadAllText(Path.Combine(variantPath, "reachgraph.truth.json"))).RootElement; + var entryPoints = truth.GetProperty("paths").EnumerateArray() + .Select(path => path[0].GetString()!) + .Distinct(StringComparer.Ordinal) + .ToList(); + var targets = truth.GetProperty("sinks").EnumerateArray().Select(s => s.GetProperty("sid").GetString()!).ToList(); + + var recomputeRequest = new ReachabilityRecomputeRequest + { + CallgraphId = ingestResponse.CallgraphId, + Subject = new ReachabilitySubject + { + ScanId = $"{caseId}:{variant}", + Component = caseId, + Version = variant + }, + EntryPoints = entryPoints, + Targets = targets, + RuntimeHits = ReadRuntimeHits(Path.Combine(variantPath, "traces.runtime.jsonl")) + }; + + var fact = await scoringService.RecomputeAsync(recomputeRequest, CancellationToken.None); + fact.States.Should().ContainSingle(state => state.Target == targets[0] && state.Reachable); + } + + private static List ReadRuntimeHits(string tracePath) + { + var hits = new List(); + if (!File.Exists(tracePath)) + { + return hits; + } + + foreach (var line in File.ReadLines(tracePath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + using var doc = JsonDocument.Parse(line); + if (doc.RootElement.TryGetProperty("sid", out var sid)) + { + hits.Add(sid.GetString()!); + } + } + + return hits; + } + + private sealed class StaticParserResolver : ICallgraphParserResolver + { + private readonly IReadOnlyDictionary parsers; + + public StaticParserResolver(IReadOnlyDictionary parsers) + { + this.parsers = parsers; + } + + public ICallgraphParser Resolve(string language) + { + if (parsers.TryGetValue(language, out var parser)) + { + return parser; + } + + throw new CallgraphParserNotFoundException(language); + } + } + + private sealed class InMemoryCallgraphRepository : ICallgraphRepository + { + private readonly Dictionary storage = new(StringComparer.Ordinal); + + public Task GetByIdAsync(string id, CancellationToken cancellationToken) + { + storage.TryGetValue(id, out var document); + return Task.FromResult(document); + } + + public Task UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(document.Id)) + { + document.Id = ObjectId.GenerateNewId().ToString(); + } + + storage[document.Id] = document; + return Task.FromResult(document); + } + } + + private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository + { + private readonly Dictionary storage = new(StringComparer.Ordinal); + + public Task GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) + { + storage.TryGetValue(subjectKey, out var document); + return Task.FromResult(document); + } + + public Task UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken) + { + storage[document.SubjectKey] = document; + return Task.FromResult(document); + } + } + + private sealed class InMemoryCallgraphArtifactStore : ICallgraphArtifactStore + { + public async Task SaveAsync(CallgraphArtifactSaveRequest request, Stream content, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(content); + + await using var buffer = new MemoryStream(); + await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + var bytes = buffer.ToArray(); + var computedHash = Convert.ToHexString(SHA256.HashData(bytes)); + + if (content.CanSeek) + { + content.Position = 0; + } + + if (!computedHash.Equals(request.Hash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Hash mismatch for {request.FileName}: expected {request.Hash} but computed {computedHash}."); + } + + return new StoredCallgraphArtifact( + Path: $"cas://fixtures/{request.Component}/{request.Version}/{request.FileName}", + Length: bytes.Length, + Hash: computedHash, + ContentType: request.ContentType); + } + } + private static string LocateRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "Directory.Build.props"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Cannot locate repository root (missing Directory.Build.props)."); + } +} diff --git a/tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj b/tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj new file mode 100644 index 000000000..f1af05fa9 --- /dev/null +++ b/tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + preview + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + fixtures\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/tests/reachability/StellaOps.Signals.Reachability.Tests/ReachabilityScoringTests.cs b/tests/reachability/StellaOps.Signals.Reachability.Tests/ReachabilityScoringTests.cs new file mode 100644 index 000000000..b43778b70 --- /dev/null +++ b/tests/reachability/StellaOps.Signals.Reachability.Tests/ReachabilityScoringTests.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using MongoDB.Bson; +using StellaOps.Signals.Models; +using StellaOps.Signals.Parsing; +using StellaOps.Signals.Persistence; +using StellaOps.Signals.Services; +using Xunit; + +namespace StellaOps.Signals.Reachability.Tests; + +public sealed class ReachabilityScoringTests +{ + private static readonly string RepoRoot = LocateRepoRoot(); + private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "reachbench-2025-expanded", "cases"); + + private static readonly (string CaseId, string Variant)[] SampleCases = + { + ("java-log4j-CVE-2021-44228-log4shell", "reachable"), + ("java-log4j-CVE-2021-44228-log4shell", "unreachable"), + ("redis-CVE-2022-0543-lua-sandbox-escape", "reachable") + }; + + public static IEnumerable CaseVariants() + { + foreach (var (caseId, variant) in SampleCases) + { + var path = Path.Combine(FixtureRoot, caseId, "images", variant); + if (Directory.Exists(path)) + { + yield return new object[] { caseId, variant }; + } + } + } + + [Theory] + [MemberData(nameof(CaseVariants))] + public async Task RecomputedFactsMatchTruthFixtures(string caseId, string variant) + { + var casePath = Path.Combine(FixtureRoot, caseId); + var variantPath = Path.Combine(casePath, "images", variant); + var truth = JsonDocument.Parse(File.ReadAllText(Path.Combine(variantPath, "reachgraph.truth.json"))).RootElement; + var sinks = truth.GetProperty("sinks").EnumerateArray().Select(x => x.GetProperty("sid").GetString()!).ToList(); + var entryPoints = truth.GetProperty("paths").EnumerateArray() + .Select(path => path[0].GetString()!) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var callgraph = await LoadCallgraphAsync(caseId, variant, variantPath); + var callgraphRepo = new InMemoryCallgraphRepository(callgraph); + var factRepo = new InMemoryReachabilityFactRepository(); + var scoringService = new ReachabilityScoringService(callgraphRepo, factRepo, TimeProvider.System, NullLogger.Instance); + + var request = BuildRequest(casePath, variant, sinks, entryPoints); + request.CallgraphId = callgraph.Id; + + var fact = await scoringService.RecomputeAsync(request, CancellationToken.None); + fact.States.Should().HaveCount(sinks.Count); + + var expectedReachable = variant == "reachable"; + foreach (var sink in sinks) + { + var state = fact.States.Single(s => s.Target == sink); + state.Reachable.Should().Be(expectedReachable, $"{caseId}:{variant} expected reachable={expectedReachable}"); + if (expectedReachable) + { + state.Path.Should().NotBeEmpty(); + state.Evidence.RuntimeHits.Should().NotBeEmpty(); + } + else + { + state.Path.Should().BeEmpty(); + state.Evidence.BlockedEdges.Should().NotBeNull(); + } + } + } + + private static ReachabilityRecomputeRequest BuildRequest(string casePath, string variant, List targets, List entryPoints) + { + var caseJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(casePath, "case.json"))).RootElement; + var variantKey = variant == "reachable" ? "reachable_variant" : "unreachable_variant"; + var variantNode = caseJson.GetProperty("ground_truth").GetProperty(variantKey); + + var blockedEdges = new List(); + if (variantNode.TryGetProperty("evidence", out var evidence) && evidence.TryGetProperty("blocked_edges", out var blockedArray)) + { + foreach (var item in blockedArray.EnumerateArray()) + { + var parts = item.GetString()?.Split("->", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts is { Length: 2 }) + { + blockedEdges.Add(new ReachabilityBlockedEdge { From = parts[0], To = parts[1] }); + } + } + } + + var runtimeHits = new List(); + var tracePath = Path.Combine(casePath, "images", variant, "traces.runtime.jsonl"); + if (File.Exists(tracePath)) + { + foreach (var line in File.ReadLines(tracePath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + using var doc = JsonDocument.Parse(line); + if (doc.RootElement.TryGetProperty("sid", out var sidProp)) + { + runtimeHits.Add(sidProp.GetString()!); + } + } + } + + return new ReachabilityRecomputeRequest + { + Subject = new ReachabilitySubject + { + ScanId = $"{Path.GetFileName(casePath)}:{variant}", + Component = Path.GetFileName(casePath), + Version = variant + }, + EntryPoints = entryPoints, + Targets = targets, + RuntimeHits = runtimeHits, + BlockedEdges = blockedEdges + }; + } + + private static async Task LoadCallgraphAsync(string caseId, string variant, string variantPath) + { + var parser = new SimpleJsonCallgraphParser("fixture"); + var nodes = new Dictionary(StringComparer.Ordinal); + var edges = new List(); + + foreach (var fileName in new[] { "callgraph.static.json", "callgraph.framework.json" }) + { + var path = Path.Combine(variantPath, fileName); + if (!File.Exists(path)) + { + continue; + } + + await using var stream = File.OpenRead(path); + var result = await parser.ParseAsync(stream, CancellationToken.None); + foreach (var node in result.Nodes) + { + nodes[node.Id] = node; + } + + edges.AddRange(result.Edges); + } + + return new CallgraphDocument + { + Id = ObjectId.GenerateNewId().ToString(), + Language = "fixture", + Component = caseId, + Version = variant, + Nodes = nodes.Values.ToList(), + Edges = edges, + Artifact = new CallgraphArtifactMetadata + { + Path = $"cas://fixtures/{caseId}/{variant}", + Hash = "stub", + ContentType = "application/json", + Length = 0 + } + }; + } + + private sealed class InMemoryCallgraphRepository : ICallgraphRepository + { + private readonly Dictionary storage; + + public InMemoryCallgraphRepository(CallgraphDocument document) + { + storage = new Dictionary(StringComparer.Ordinal) + { + [document.Id] = document + }; + } + + public Task UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken) + { + storage[document.Id] = document; + return Task.FromResult(document); + } + + public Task GetByIdAsync(string id, CancellationToken cancellationToken) + { + storage.TryGetValue(id, out var document); + return Task.FromResult(document); + } + } + + private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository + { + private readonly Dictionary storage = new(StringComparer.Ordinal); + + public Task GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) + { + storage.TryGetValue(subjectKey, out var document); + return Task.FromResult(document); + } + + public Task UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken) + { + storage[document.SubjectKey] = document; + return Task.FromResult(document); + } + } + private static string LocateRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "Directory.Build.props"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Cannot locate repository root (missing Directory.Build.props)."); + } +} diff --git a/tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj b/tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj new file mode 100644 index 000000000..2f2ce1fbe --- /dev/null +++ b/tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + preview + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + fixtures\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/INDEX.json b/tests/reachability/fixtures/reachbench-2025-expanded/INDEX.json new file mode 100644 index 000000000..3217c3355 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/INDEX.json @@ -0,0 +1,444 @@ +{ + "version": "0.1", + "generated_at": "2025-11-07T22:40:04Z", + "cases": [ + { + "id": "runc-CVE-2024-21626-symlink-breakout", + "primary_axis": "container-escape", + "tags": [ + "symlink", + "filesystem", + "userns" + ], + "languages": [ + "binary" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 9.0, + "references": [ + "cve:CVE-2024-21626" + ] + }, + { + "id": "linux-cgroups-CVE-2022-0492-release_agent", + "primary_axis": "container-escape", + "tags": [ + "cgroups", + "kernel", + "priv-esc" + ], + "languages": [ + "binary" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 9.0, + "references": [ + "cve:CVE-2022-0492" + ] + }, + { + "id": "glibc-CVE-2023-4911-looney-tunables", + "primary_axis": "binary-hybrid", + "tags": [ + "env-vars", + "libc", + "ldso" + ], + "languages": [ + "c" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2023-4911" + ] + }, + { + "id": "curl-CVE-2023-38545-socks5-heap", + "primary_axis": "binary-hybrid", + "tags": [ + "networking", + "proxy", + "heap" + ], + "languages": [ + "c" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2023-38545" + ] + }, + { + "id": "openssl-CVE-2022-3602-x509-name-constraints", + "primary_axis": "binary-hybrid", + "tags": [ + "x509", + "parser", + "stack-overflow" + ], + "languages": [ + "c" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2022-3602" + ] + }, + { + "id": "openssh-CVE-2024-6387-regreSSHion", + "primary_axis": "binary-hybrid", + "tags": [ + "signal-handler", + "daemon" + ], + "languages": [ + "c" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2024-6387" + ] + }, + { + "id": "redis-CVE-2022-0543-lua-sandbox-escape", + "primary_axis": "binary-hybrid", + "tags": [ + "lua", + "sandbox", + "rce" + ], + "languages": [ + "c", + "lua" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2022-0543" + ] + }, + { + "id": "java-log4j-CVE-2021-44228-log4shell", + "primary_axis": "lang-jvm", + "tags": [ + "jndi", + "deserialization", + "rce" + ], + "languages": [ + "java" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 9.8, + "references": [ + "cve:CVE-2021-44228" + ] + }, + { + "id": "java-spring-CVE-2022-22965-spring4shell", + "primary_axis": "lang-jvm", + "tags": [ + "binding", + "reflection", + "rce" + ], + "languages": [ + "java" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 9.8, + "references": [ + "cve:CVE-2022-22965" + ] + }, + { + "id": "java-jackson-CVE-2019-12384-polymorphic-deser", + "primary_axis": "lang-jvm", + "tags": [ + "deserialization", + "polymorphism" + ], + "languages": [ + "java" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2019-12384" + ] + }, + { + "id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset", + "primary_axis": "lang-dotnet", + "tags": [ + "protocol", + "http2", + "dos" + ], + "languages": [ + "dotnet" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2023-44487" + ] + }, + { + "id": "dotnet-newtonsoft-deser-TBD", + "primary_axis": "lang-dotnet", + "tags": [ + "deserialization", + "json", + "polymorphic" + ], + "languages": [ + "dotnet" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [] + }, + { + "id": "go-ssh-CVE-2020-9283-keyexchange", + "primary_axis": "lang-go", + "tags": [ + "crypto", + "handshake" + ], + "languages": [ + "go" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2020-9283" + ] + }, + { + "id": "go-gateway-reflection-auth-bypass", + "primary_axis": "lang-go", + "tags": [ + "grpc", + "reflection", + "authz-gap" + ], + "languages": [ + "go" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [] + }, + { + "id": "node-tar-CVE-2021-37713-path-traversal", + "primary_axis": "lang-node", + "tags": [ + "path-traversal", + "archive-extract" + ], + "languages": [ + "node" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2021-37713" + ] + }, + { + "id": "node-express-middleware-order-auth-bypass", + "primary_axis": "lang-node", + "tags": [ + "middleware-order", + "authz" + ], + "languages": [ + "node" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [] + }, + { + "id": "python-jinja2-CVE-2019-10906-template-injection", + "primary_axis": "lang-python", + "tags": [ + "template-injection" + ], + "languages": [ + "python" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2019-10906" + ] + }, + { + "id": "python-django-CVE-2019-19844-sqli-like", + "primary_axis": "lang-python", + "tags": [ + "sqli", + "orm" + ], + "languages": [ + "python" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2019-19844" + ] + }, + { + "id": "python-urllib3-dos-regex-TBD", + "primary_axis": "lang-python", + "tags": [ + "regex-dos", + "parser" + ], + "languages": [ + "python" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [] + }, + { + "id": "php-phpmailer-CVE-2016-10033-rce", + "primary_axis": "lang-php", + "tags": [ + "rce", + "email" + ], + "languages": [ + "php" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2016-10033" + ] + }, + { + "id": "wordpress-core-CVE-2022-21661-sqli", + "primary_axis": "lang-php", + "tags": [ + "sqli", + "core" + ], + "languages": [ + "php" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2022-21661" + ] + }, + { + "id": "rails-CVE-2019-5418-file-content-disclosure", + "primary_axis": "lang-ruby", + "tags": [ + "path-traversal", + "mime" + ], + "languages": [ + "ruby" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [ + "cve:CVE-2019-5418" + ] + }, + { + "id": "rust-axum-header-parsing-TBD", + "primary_axis": "lang-rust", + "tags": [ + "parser", + "config-sensitive" + ], + "languages": [ + "rust" + ], + "variants": [ + "reachable", + "unreachable" + ], + "severity_cvss": 7.5, + "references": [] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/README.md new file mode 100644 index 000000000..6fe5963d1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/README.md @@ -0,0 +1,2 @@ +# ReachBench-2025 Expanded Kit (Skeleton) +This is a scaffold containing diverse cases across languages and reach paths. Replace STUBs with real build configs, symbols, and call graphs. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/case.json new file mode 100644 index 000000000..ee4d520c3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/case.json @@ -0,0 +1,46 @@ +{ + "id": "curl-CVE-2023-38545-socks5-heap", + "cve": "CVE-2023-38545", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://curl:curl.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://curl:curl.c#entry", + "sym://curl:curl.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://curl:curl.c#entry -> sym://curl:curl.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/docs/README.md new file mode 100644 index 000000000..c3005d571 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/docs/README.md @@ -0,0 +1,15 @@ +# curl-CVE-2023-38545-socks5-heap +Primary axis: binary-hybrid +Tags: networking, proxy, heap +Languages: c + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..eba0a64c3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/curl-CVE-2023-38545-socks5-heap:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.static.json new file mode 100644 index 000000000..51e50e846 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://curl:curl.c#entry" + }, + { + "sid": "sym://curl:curl.c#sink" + } + ], + "edges": [ + { + "from": "sym://curl:curl.c#entry", + "to": "sym://curl:curl.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/manifest.json new file mode 100644 index 000000000..c4ecf464f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/curl-CVE-2023-38545-socks5-heap:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..e0b3384f7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://curl:curl.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://curl:curl.c#entry", + "sym://curl:curl.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/symbols.json new file mode 100644 index 000000000..8418109e8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/curl@0.0.1", + "files": [ + { + "path": "/src/curl.c", + "funcs": [ + { + "sid": "sym://curl:curl.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://curl:curl.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..29df9022e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://curl:curl.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://curl:curl.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/vex.openvex.json new file mode 100644 index 000000000..7fb2e0f81 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2023-38545", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..c61435ee1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/curl-CVE-2023-38545-socks5-heap:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..51e50e846 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://curl:curl.c#entry" + }, + { + "sid": "sym://curl:curl.c#sink" + } + ], + "edges": [ + { + "from": "sym://curl:curl.c#entry", + "to": "sym://curl:curl.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/manifest.json new file mode 100644 index 000000000..df343b8b0 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/curl-CVE-2023-38545-socks5-heap:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..e0b3384f7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://curl:curl.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://curl:curl.c#entry", + "sym://curl:curl.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/symbols.json new file mode 100644 index 000000000..8418109e8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/curl@0.0.1", + "files": [ + { + "path": "/src/curl.c", + "funcs": [ + { + "sid": "sym://curl:curl.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://curl:curl.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..fb0b525c9 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://curl:curl.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..81bf7b9a5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/curl-CVE-2023-38545-socks5-heap/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2023-38545", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/case.json new file mode 100644 index 000000000..05f645a1f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/case.json @@ -0,0 +1,46 @@ +{ + "id": "dotnet-kestrel-CVE-2023-44487-http2-rapid-reset", + "cve": "CVE-2023-44487", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://dotnet:dotnet.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://dotnet:dotnet.c#entry", + "sym://dotnet:dotnet.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://dotnet:dotnet.c#entry -> sym://dotnet:dotnet.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/docs/README.md new file mode 100644 index 000000000..e2ab14128 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/docs/README.md @@ -0,0 +1,15 @@ +# dotnet-kestrel-CVE-2023-44487-http2-rapid-reset +Primary axis: lang-dotnet +Tags: protocol, http2, dos +Languages: dotnet + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..b32578cbf --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..f280f3ea8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://dotnet:Startup#Configure", + "to": "sym://aspnet:UseEndpoints", + "kind": "pipeline" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.static.json new file mode 100644 index 000000000..be9e912a8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://dotnet:dotnet.c#entry" + }, + { + "sid": "sym://dotnet:dotnet.c#sink" + } + ], + "edges": [ + { + "from": "sym://dotnet:dotnet.c#entry", + "to": "sym://dotnet:dotnet.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/manifest.json new file mode 100644 index 000000000..763f1cea2 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..8490d250f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://dotnet:dotnet.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://dotnet:dotnet.c#entry", + "sym://dotnet:dotnet.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/symbols.json new file mode 100644 index 000000000..dcd3f5385 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/dotnet@0.0.1", + "files": [ + { + "path": "/src/dotnet.c", + "funcs": [ + { + "sid": "sym://dotnet:dotnet.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://dotnet:dotnet.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..5fd3f9698 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://dotnet:dotnet.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://dotnet:dotnet.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/vex.openvex.json new file mode 100644 index 000000000..34c69c4ef --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2023-44487", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..2b0e4a84d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..f280f3ea8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://dotnet:Startup#Configure", + "to": "sym://aspnet:UseEndpoints", + "kind": "pipeline" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..be9e912a8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://dotnet:dotnet.c#entry" + }, + { + "sid": "sym://dotnet:dotnet.c#sink" + } + ], + "edges": [ + { + "from": "sym://dotnet:dotnet.c#entry", + "to": "sym://dotnet:dotnet.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/manifest.json new file mode 100644 index 000000000..38e73639f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..8490d250f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://dotnet:dotnet.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://dotnet:dotnet.c#entry", + "sym://dotnet:dotnet.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/symbols.json new file mode 100644 index 000000000..dcd3f5385 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/dotnet@0.0.1", + "files": [ + { + "path": "/src/dotnet.c", + "funcs": [ + { + "sid": "sym://dotnet:dotnet.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://dotnet:dotnet.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..f3cc6c100 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://dotnet:dotnet.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..8d117be4d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-kestrel-CVE-2023-44487-http2-rapid-reset/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2023-44487", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/case.json new file mode 100644 index 000000000..5b49f315f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/case.json @@ -0,0 +1,46 @@ +{ + "id": "dotnet-newtonsoft-deser-TBD", + "cve": "N/A", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://dotnet:dotnet.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://dotnet:dotnet.c#entry", + "sym://dotnet:dotnet.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://dotnet:dotnet.c#entry -> sym://dotnet:dotnet.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/docs/README.md new file mode 100644 index 000000000..699e0d7e2 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/docs/README.md @@ -0,0 +1,15 @@ +# dotnet-newtonsoft-deser-TBD +Primary axis: lang-dotnet +Tags: deserialization, json, polymorphic +Languages: dotnet + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..d9f82f1bf --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/dotnet-newtonsoft-deser-TBD:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..f280f3ea8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://dotnet:Startup#Configure", + "to": "sym://aspnet:UseEndpoints", + "kind": "pipeline" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.static.json new file mode 100644 index 000000000..be9e912a8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://dotnet:dotnet.c#entry" + }, + { + "sid": "sym://dotnet:dotnet.c#sink" + } + ], + "edges": [ + { + "from": "sym://dotnet:dotnet.c#entry", + "to": "sym://dotnet:dotnet.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/manifest.json new file mode 100644 index 000000000..a7645af0d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/dotnet-newtonsoft-deser-TBD:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..8490d250f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://dotnet:dotnet.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://dotnet:dotnet.c#entry", + "sym://dotnet:dotnet.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/symbols.json new file mode 100644 index 000000000..dcd3f5385 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/dotnet@0.0.1", + "files": [ + { + "path": "/src/dotnet.c", + "funcs": [ + { + "sid": "sym://dotnet:dotnet.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://dotnet:dotnet.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..5fd3f9698 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://dotnet:dotnet.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://dotnet:dotnet.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/vex.openvex.json new file mode 100644 index 000000000..a9c299cc5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..e3346d699 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/dotnet-newtonsoft-deser-TBD:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..f280f3ea8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://dotnet:Startup#Configure", + "to": "sym://aspnet:UseEndpoints", + "kind": "pipeline" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..be9e912a8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://dotnet:dotnet.c#entry" + }, + { + "sid": "sym://dotnet:dotnet.c#sink" + } + ], + "edges": [ + { + "from": "sym://dotnet:dotnet.c#entry", + "to": "sym://dotnet:dotnet.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/manifest.json new file mode 100644 index 000000000..ca3253816 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/dotnet-newtonsoft-deser-TBD:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..8490d250f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://dotnet:dotnet.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://dotnet:dotnet.c#entry", + "sym://dotnet:dotnet.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/symbols.json new file mode 100644 index 000000000..dcd3f5385 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/dotnet@0.0.1", + "files": [ + { + "path": "/src/dotnet.c", + "funcs": [ + { + "sid": "sym://dotnet:dotnet.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://dotnet:dotnet.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..f3cc6c100 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://dotnet:dotnet.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..eb7c35e0b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/dotnet-newtonsoft-deser-TBD/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/case.json new file mode 100644 index 000000000..44c62ee7e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/case.json @@ -0,0 +1,46 @@ +{ + "id": "glibc-CVE-2023-4911-looney-tunables", + "cve": "CVE-2023-4911", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://glibc:glibc.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://glibc:glibc.c#entry", + "sym://glibc:glibc.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://glibc:glibc.c#entry -> sym://glibc:glibc.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/docs/README.md new file mode 100644 index 000000000..14f3bdb33 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/docs/README.md @@ -0,0 +1,15 @@ +# glibc-CVE-2023-4911-looney-tunables +Primary axis: binary-hybrid +Tags: env-vars, libc, ldso +Languages: c + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..43420b921 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/glibc-CVE-2023-4911-looney-tunables:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.static.json new file mode 100644 index 000000000..c53742bff --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://glibc:glibc.c#entry" + }, + { + "sid": "sym://glibc:glibc.c#sink" + } + ], + "edges": [ + { + "from": "sym://glibc:glibc.c#entry", + "to": "sym://glibc:glibc.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/manifest.json new file mode 100644 index 000000000..3ac7506e0 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/glibc-CVE-2023-4911-looney-tunables:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..171e31b94 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://glibc:glibc.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://glibc:glibc.c#entry", + "sym://glibc:glibc.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/symbols.json new file mode 100644 index 000000000..84134e556 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/glibc@0.0.1", + "files": [ + { + "path": "/src/glibc.c", + "funcs": [ + { + "sid": "sym://glibc:glibc.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://glibc:glibc.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..0a32a8bfc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://glibc:glibc.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://glibc:glibc.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/vex.openvex.json new file mode 100644 index 000000000..be84e9c73 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2023-4911", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..f704075e6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/glibc-CVE-2023-4911-looney-tunables:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..c53742bff --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://glibc:glibc.c#entry" + }, + { + "sid": "sym://glibc:glibc.c#sink" + } + ], + "edges": [ + { + "from": "sym://glibc:glibc.c#entry", + "to": "sym://glibc:glibc.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/manifest.json new file mode 100644 index 000000000..f1d40604b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/glibc-CVE-2023-4911-looney-tunables:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..171e31b94 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://glibc:glibc.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://glibc:glibc.c#entry", + "sym://glibc:glibc.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/symbols.json new file mode 100644 index 000000000..84134e556 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/glibc@0.0.1", + "files": [ + { + "path": "/src/glibc.c", + "funcs": [ + { + "sid": "sym://glibc:glibc.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://glibc:glibc.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..adaad02a1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://glibc:glibc.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..06aa27535 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/glibc-CVE-2023-4911-looney-tunables/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2023-4911", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/case.json new file mode 100644 index 000000000..0492aa3b2 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/case.json @@ -0,0 +1,46 @@ +{ + "id": "go-gateway-reflection-auth-bypass", + "cve": "N/A", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://go:go.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://go:go.c#entry", + "sym://go:go.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://go:go.c#entry -> sym://go:go.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/docs/README.md new file mode 100644 index 000000000..a4e815f86 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/docs/README.md @@ -0,0 +1,15 @@ +# go-gateway-reflection-auth-bypass +Primary axis: lang-go +Tags: grpc, reflection, authz-gap +Languages: go + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..6bf3a3fdc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/go-gateway-reflection-auth-bypass:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.static.json new file mode 100644 index 000000000..748757d4e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://go:go.c#entry" + }, + { + "sid": "sym://go:go.c#sink" + } + ], + "edges": [ + { + "from": "sym://go:go.c#entry", + "to": "sym://go:go.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/manifest.json new file mode 100644 index 000000000..d1f2e178c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/go-gateway-reflection-auth-bypass:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..b29ccad76 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://go:go.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://go:go.c#entry", + "sym://go:go.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/symbols.json new file mode 100644 index 000000000..fd69f70b1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/go@0.0.1", + "files": [ + { + "path": "/src/go.c", + "funcs": [ + { + "sid": "sym://go:go.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://go:go.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..07ece784b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://go:go.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://go:go.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/vex.openvex.json new file mode 100644 index 000000000..a9c299cc5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..db7c250c9 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/go-gateway-reflection-auth-bypass:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..748757d4e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://go:go.c#entry" + }, + { + "sid": "sym://go:go.c#sink" + } + ], + "edges": [ + { + "from": "sym://go:go.c#entry", + "to": "sym://go:go.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/manifest.json new file mode 100644 index 000000000..e252d8835 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/go-gateway-reflection-auth-bypass:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..b29ccad76 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://go:go.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://go:go.c#entry", + "sym://go:go.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/symbols.json new file mode 100644 index 000000000..fd69f70b1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/go@0.0.1", + "files": [ + { + "path": "/src/go.c", + "funcs": [ + { + "sid": "sym://go:go.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://go:go.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..bc4c7e0c4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://go:go.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..eb7c35e0b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-gateway-reflection-auth-bypass/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/case.json new file mode 100644 index 000000000..e9400f7f1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/case.json @@ -0,0 +1,46 @@ +{ + "id": "go-ssh-CVE-2020-9283-keyexchange", + "cve": "CVE-2020-9283", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://go:go.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://go:go.c#entry", + "sym://go:go.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://go:go.c#entry -> sym://go:go.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/docs/README.md new file mode 100644 index 000000000..554d8e835 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/docs/README.md @@ -0,0 +1,15 @@ +# go-ssh-CVE-2020-9283-keyexchange +Primary axis: lang-go +Tags: crypto, handshake +Languages: go + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..bd2452b8d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/go-ssh-CVE-2020-9283-keyexchange:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.static.json new file mode 100644 index 000000000..748757d4e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://go:go.c#entry" + }, + { + "sid": "sym://go:go.c#sink" + } + ], + "edges": [ + { + "from": "sym://go:go.c#entry", + "to": "sym://go:go.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/manifest.json new file mode 100644 index 000000000..314fa279e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/go-ssh-CVE-2020-9283-keyexchange:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..b29ccad76 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://go:go.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://go:go.c#entry", + "sym://go:go.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/symbols.json new file mode 100644 index 000000000..fd69f70b1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/go@0.0.1", + "files": [ + { + "path": "/src/go.c", + "funcs": [ + { + "sid": "sym://go:go.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://go:go.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..07ece784b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://go:go.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://go:go.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/vex.openvex.json new file mode 100644 index 000000000..5b63537f4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2020-9283", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..c036a3ba3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/go-ssh-CVE-2020-9283-keyexchange:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..748757d4e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://go:go.c#entry" + }, + { + "sid": "sym://go:go.c#sink" + } + ], + "edges": [ + { + "from": "sym://go:go.c#entry", + "to": "sym://go:go.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/manifest.json new file mode 100644 index 000000000..590cdce10 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/go-ssh-CVE-2020-9283-keyexchange:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..b29ccad76 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://go:go.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://go:go.c#entry", + "sym://go:go.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/symbols.json new file mode 100644 index 000000000..fd69f70b1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/go@0.0.1", + "files": [ + { + "path": "/src/go.c", + "funcs": [ + { + "sid": "sym://go:go.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://go:go.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..bc4c7e0c4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://go:go.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..0bb3923d0 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/go-ssh-CVE-2020-9283-keyexchange/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2020-9283", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/case.json new file mode 100644 index 000000000..11187141f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/case.json @@ -0,0 +1,46 @@ +{ + "id": "java-jackson-CVE-2019-12384-polymorphic-deser", + "cve": "CVE-2019-12384", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://java:java.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://java:java.c#entry -> sym://java:java.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/docs/README.md new file mode 100644 index 000000000..8ee2d9d3f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/docs/README.md @@ -0,0 +1,15 @@ +# java-jackson-CVE-2019-12384-polymorphic-deser +Primary axis: lang-jvm +Tags: deserialization, polymorphism +Languages: java + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..e99537197 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/java-jackson-CVE-2019-12384-polymorphic-deser:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..4c65b9afe --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://spring:DispatcherServlet#doDispatch", + "to": "sym://java:java.c#entry", + "kind": "framework" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.static.json new file mode 100644 index 000000000..ad4bcb5b8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://java:java.c#entry" + }, + { + "sid": "sym://java:java.c#sink" + } + ], + "edges": [ + { + "from": "sym://java:java.c#entry", + "to": "sym://java:java.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/manifest.json new file mode 100644 index 000000000..c5b4dc302 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/java-jackson-CVE-2019-12384-polymorphic-deser:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..4f5e2dd61 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://java:java.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/symbols.json new file mode 100644 index 000000000..f41e9ccf5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/java@0.0.1", + "files": [ + { + "path": "/src/java.c", + "funcs": [ + { + "sid": "sym://java:java.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://java:java.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..285a0ffab --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://java:java.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://java:java.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/vex.openvex.json new file mode 100644 index 000000000..cf7bad2f1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-12384", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..5eb27ea59 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/java-jackson-CVE-2019-12384-polymorphic-deser:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..4c65b9afe --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://spring:DispatcherServlet#doDispatch", + "to": "sym://java:java.c#entry", + "kind": "framework" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..ad4bcb5b8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://java:java.c#entry" + }, + { + "sid": "sym://java:java.c#sink" + } + ], + "edges": [ + { + "from": "sym://java:java.c#entry", + "to": "sym://java:java.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/manifest.json new file mode 100644 index 000000000..6085f892a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/java-jackson-CVE-2019-12384-polymorphic-deser:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..4f5e2dd61 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://java:java.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/symbols.json new file mode 100644 index 000000000..f41e9ccf5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/java@0.0.1", + "files": [ + { + "path": "/src/java.c", + "funcs": [ + { + "sid": "sym://java:java.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://java:java.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..cdecd4ea7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://java:java.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..47edfb577 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-jackson-CVE-2019-12384-polymorphic-deser/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-12384", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/case.json new file mode 100644 index 000000000..dcf2c931d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/case.json @@ -0,0 +1,46 @@ +{ + "id": "java-log4j-CVE-2021-44228-log4shell", + "cve": "CVE-2021-44228", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://java:java.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://java:java.c#entry -> sym://java:java.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/docs/README.md new file mode 100644 index 000000000..287606e62 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/docs/README.md @@ -0,0 +1,15 @@ +# java-log4j-CVE-2021-44228-log4shell +Primary axis: lang-jvm +Tags: jndi, deserialization, rce +Languages: java + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..655ed8f38 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/java-log4j-CVE-2021-44228-log4shell:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..4c65b9afe --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://spring:DispatcherServlet#doDispatch", + "to": "sym://java:java.c#entry", + "kind": "framework" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.static.json new file mode 100644 index 000000000..ad4bcb5b8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://java:java.c#entry" + }, + { + "sid": "sym://java:java.c#sink" + } + ], + "edges": [ + { + "from": "sym://java:java.c#entry", + "to": "sym://java:java.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/manifest.json new file mode 100644 index 000000000..2af2351c1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/java-log4j-CVE-2021-44228-log4shell:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..4f5e2dd61 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://java:java.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/symbols.json new file mode 100644 index 000000000..f41e9ccf5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/java@0.0.1", + "files": [ + { + "path": "/src/java.c", + "funcs": [ + { + "sid": "sym://java:java.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://java:java.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..285a0ffab --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://java:java.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://java:java.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/vex.openvex.json new file mode 100644 index 000000000..6f4449584 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2021-44228", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..73d67b03c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/java-log4j-CVE-2021-44228-log4shell:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..4c65b9afe --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://spring:DispatcherServlet#doDispatch", + "to": "sym://java:java.c#entry", + "kind": "framework" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..ad4bcb5b8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://java:java.c#entry" + }, + { + "sid": "sym://java:java.c#sink" + } + ], + "edges": [ + { + "from": "sym://java:java.c#entry", + "to": "sym://java:java.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/manifest.json new file mode 100644 index 000000000..345932625 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/java-log4j-CVE-2021-44228-log4shell:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..4f5e2dd61 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://java:java.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/symbols.json new file mode 100644 index 000000000..f41e9ccf5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/java@0.0.1", + "files": [ + { + "path": "/src/java.c", + "funcs": [ + { + "sid": "sym://java:java.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://java:java.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..cdecd4ea7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://java:java.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..6ccd2a3ca --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-log4j-CVE-2021-44228-log4shell/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2021-44228", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/case.json new file mode 100644 index 000000000..32d6d1a70 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/case.json @@ -0,0 +1,46 @@ +{ + "id": "java-spring-CVE-2022-22965-spring4shell", + "cve": "CVE-2022-22965", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://java:java.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://java:java.c#entry -> sym://java:java.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/docs/README.md new file mode 100644 index 000000000..b956f75ee --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/docs/README.md @@ -0,0 +1,15 @@ +# java-spring-CVE-2022-22965-spring4shell +Primary axis: lang-jvm +Tags: binding, reflection, rce +Languages: java + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..eccf4efa3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/java-spring-CVE-2022-22965-spring4shell:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..4c65b9afe --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://spring:DispatcherServlet#doDispatch", + "to": "sym://java:java.c#entry", + "kind": "framework" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.static.json new file mode 100644 index 000000000..ad4bcb5b8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://java:java.c#entry" + }, + { + "sid": "sym://java:java.c#sink" + } + ], + "edges": [ + { + "from": "sym://java:java.c#entry", + "to": "sym://java:java.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/manifest.json new file mode 100644 index 000000000..b25ae3351 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/java-spring-CVE-2022-22965-spring4shell:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..4f5e2dd61 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://java:java.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/symbols.json new file mode 100644 index 000000000..f41e9ccf5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/java@0.0.1", + "files": [ + { + "path": "/src/java.c", + "funcs": [ + { + "sid": "sym://java:java.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://java:java.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..285a0ffab --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://java:java.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://java:java.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/vex.openvex.json new file mode 100644 index 000000000..a67afde39 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-22965", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..b979e782c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/java-spring-CVE-2022-22965-spring4shell:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..4c65b9afe --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://spring:DispatcherServlet#doDispatch", + "to": "sym://java:java.c#entry", + "kind": "framework" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..ad4bcb5b8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://java:java.c#entry" + }, + { + "sid": "sym://java:java.c#sink" + } + ], + "edges": [ + { + "from": "sym://java:java.c#entry", + "to": "sym://java:java.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/manifest.json new file mode 100644 index 000000000..47dd702fc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/java-spring-CVE-2022-22965-spring4shell:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..4f5e2dd61 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://java:java.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://java:java.c#entry", + "sym://java:java.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/symbols.json new file mode 100644 index 000000000..f41e9ccf5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/java@0.0.1", + "files": [ + { + "path": "/src/java.c", + "funcs": [ + { + "sid": "sym://java:java.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://java:java.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..cdecd4ea7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://java:java.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..cba12ec6b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/java-spring-CVE-2022-22965-spring4shell/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-22965", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/case.json new file mode 100644 index 000000000..fa6566251 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/case.json @@ -0,0 +1,46 @@ +{ + "id": "linux-cgroups-CVE-2022-0492-release_agent", + "cve": "CVE-2022-0492", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://linux:linux.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://linux:linux.c#entry", + "sym://linux:linux.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://linux:linux.c#entry -> sym://linux:linux.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/docs/README.md new file mode 100644 index 000000000..064ec21f7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/docs/README.md @@ -0,0 +1,15 @@ +# linux-cgroups-CVE-2022-0492-release_agent +Primary axis: container-escape +Tags: cgroups, kernel, priv-esc +Languages: binary + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..81a634aeb --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/linux-cgroups-CVE-2022-0492-release_agent:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.static.json new file mode 100644 index 000000000..a925f1553 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://linux:linux.c#entry" + }, + { + "sid": "sym://linux:linux.c#sink" + } + ], + "edges": [ + { + "from": "sym://linux:linux.c#entry", + "to": "sym://linux:linux.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/manifest.json new file mode 100644 index 000000000..a3e784eac --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/linux-cgroups-CVE-2022-0492-release_agent:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..1b95019d9 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://linux:linux.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://linux:linux.c#entry", + "sym://linux:linux.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/symbols.json new file mode 100644 index 000000000..b8d714c58 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/linux@0.0.1", + "files": [ + { + "path": "/src/linux.c", + "funcs": [ + { + "sid": "sym://linux:linux.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://linux:linux.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..809d5b2dd --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://linux:linux.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://linux:linux.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/vex.openvex.json new file mode 100644 index 000000000..2a9940d49 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-0492", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..a6176c434 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/linux-cgroups-CVE-2022-0492-release_agent:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..a925f1553 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://linux:linux.c#entry" + }, + { + "sid": "sym://linux:linux.c#sink" + } + ], + "edges": [ + { + "from": "sym://linux:linux.c#entry", + "to": "sym://linux:linux.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/manifest.json new file mode 100644 index 000000000..c9eda43b9 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/linux-cgroups-CVE-2022-0492-release_agent:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..1b95019d9 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://linux:linux.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://linux:linux.c#entry", + "sym://linux:linux.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/symbols.json new file mode 100644 index 000000000..b8d714c58 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/linux@0.0.1", + "files": [ + { + "path": "/src/linux.c", + "funcs": [ + { + "sid": "sym://linux:linux.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://linux:linux.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..3fcbccbbe --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://linux:linux.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..2b4f0d675 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/linux-cgroups-CVE-2022-0492-release_agent/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-0492", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/case.json new file mode 100644 index 000000000..33bbc9070 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/case.json @@ -0,0 +1,46 @@ +{ + "id": "node-express-middleware-order-auth-bypass", + "cve": "N/A", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://node:node.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://node:node.c#entry", + "sym://node:node.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://node:node.c#entry -> sym://node:node.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/docs/README.md new file mode 100644 index 000000000..56c0c3f5b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/docs/README.md @@ -0,0 +1,15 @@ +# node-express-middleware-order-auth-bypass +Primary axis: lang-node +Tags: middleware-order, authz +Languages: node + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..0f6e5dbb1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/node-express-middleware-order-auth-bypass:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..e7aafb7e4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://node:express#route:/vuln", + "to": "sym://node:node.c#entry", + "kind": "middleware" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.static.json new file mode 100644 index 000000000..1942a53bc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://node:node.c#entry" + }, + { + "sid": "sym://node:node.c#sink" + } + ], + "edges": [ + { + "from": "sym://node:node.c#entry", + "to": "sym://node:node.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/manifest.json new file mode 100644 index 000000000..820be7c3e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/node-express-middleware-order-auth-bypass:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..ec198e135 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://node:node.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://node:node.c#entry", + "sym://node:node.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/symbols.json new file mode 100644 index 000000000..99b86a64c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/node@0.0.1", + "files": [ + { + "path": "/src/node.c", + "funcs": [ + { + "sid": "sym://node:node.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://node:node.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..9e709051d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://node:node.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://node:node.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/vex.openvex.json new file mode 100644 index 000000000..a9c299cc5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..c2fb07b90 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/node-express-middleware-order-auth-bypass:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..e7aafb7e4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://node:express#route:/vuln", + "to": "sym://node:node.c#entry", + "kind": "middleware" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..1942a53bc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://node:node.c#entry" + }, + { + "sid": "sym://node:node.c#sink" + } + ], + "edges": [ + { + "from": "sym://node:node.c#entry", + "to": "sym://node:node.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/manifest.json new file mode 100644 index 000000000..29b2035c1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/node-express-middleware-order-auth-bypass:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..ec198e135 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://node:node.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://node:node.c#entry", + "sym://node:node.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/symbols.json new file mode 100644 index 000000000..99b86a64c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/node@0.0.1", + "files": [ + { + "path": "/src/node.c", + "funcs": [ + { + "sid": "sym://node:node.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://node:node.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..c915d801d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://node:node.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..eb7c35e0b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-express-middleware-order-auth-bypass/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/case.json new file mode 100644 index 000000000..06da6d745 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/case.json @@ -0,0 +1,46 @@ +{ + "id": "node-tar-CVE-2021-37713-path-traversal", + "cve": "CVE-2021-37713", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://node:node.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://node:node.c#entry", + "sym://node:node.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://node:node.c#entry -> sym://node:node.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/docs/README.md new file mode 100644 index 000000000..bc4a2d7f1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/docs/README.md @@ -0,0 +1,15 @@ +# node-tar-CVE-2021-37713-path-traversal +Primary axis: lang-node +Tags: path-traversal, archive-extract +Languages: node + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..db3c76ac8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/node-tar-CVE-2021-37713-path-traversal:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..e7aafb7e4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://node:express#route:/vuln", + "to": "sym://node:node.c#entry", + "kind": "middleware" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.static.json new file mode 100644 index 000000000..1942a53bc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://node:node.c#entry" + }, + { + "sid": "sym://node:node.c#sink" + } + ], + "edges": [ + { + "from": "sym://node:node.c#entry", + "to": "sym://node:node.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/manifest.json new file mode 100644 index 000000000..319e2fc59 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/node-tar-CVE-2021-37713-path-traversal:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..ec198e135 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://node:node.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://node:node.c#entry", + "sym://node:node.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/symbols.json new file mode 100644 index 000000000..99b86a64c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/node@0.0.1", + "files": [ + { + "path": "/src/node.c", + "funcs": [ + { + "sid": "sym://node:node.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://node:node.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..9e709051d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://node:node.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://node:node.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/vex.openvex.json new file mode 100644 index 000000000..79a68ab9e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2021-37713", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..cf8dfbe16 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/node-tar-CVE-2021-37713-path-traversal:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..e7aafb7e4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.framework.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0", + "edges": [ + { + "from": "sym://node:express#route:/vuln", + "to": "sym://node:node.c#entry", + "kind": "middleware" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..1942a53bc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://node:node.c#entry" + }, + { + "sid": "sym://node:node.c#sink" + } + ], + "edges": [ + { + "from": "sym://node:node.c#entry", + "to": "sym://node:node.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/manifest.json new file mode 100644 index 000000000..fe5a92341 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/node-tar-CVE-2021-37713-path-traversal:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..ec198e135 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://node:node.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://node:node.c#entry", + "sym://node:node.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/symbols.json new file mode 100644 index 000000000..99b86a64c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/node@0.0.1", + "files": [ + { + "path": "/src/node.c", + "funcs": [ + { + "sid": "sym://node:node.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://node:node.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..c915d801d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://node:node.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..a2fffede0 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/node-tar-CVE-2021-37713-path-traversal/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2021-37713", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/case.json new file mode 100644 index 000000000..911cb9797 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/case.json @@ -0,0 +1,46 @@ +{ + "id": "openssh-CVE-2024-6387-regreSSHion", + "cve": "CVE-2024-6387", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://openssh:openssh.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://openssh:openssh.c#entry", + "sym://openssh:openssh.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://openssh:openssh.c#entry -> sym://openssh:openssh.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/docs/README.md new file mode 100644 index 000000000..b82290fad --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/docs/README.md @@ -0,0 +1,15 @@ +# openssh-CVE-2024-6387-regreSSHion +Primary axis: binary-hybrid +Tags: signal-handler, daemon +Languages: c + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..09ecb47c5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/openssh-CVE-2024-6387-regreSSHion:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.static.json new file mode 100644 index 000000000..9a60bc46f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://openssh:openssh.c#entry" + }, + { + "sid": "sym://openssh:openssh.c#sink" + } + ], + "edges": [ + { + "from": "sym://openssh:openssh.c#entry", + "to": "sym://openssh:openssh.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/manifest.json new file mode 100644 index 000000000..102de6964 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/openssh-CVE-2024-6387-regreSSHion:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..29d415030 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://openssh:openssh.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://openssh:openssh.c#entry", + "sym://openssh:openssh.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/symbols.json new file mode 100644 index 000000000..2f060b67b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/openssh@0.0.1", + "files": [ + { + "path": "/src/openssh.c", + "funcs": [ + { + "sid": "sym://openssh:openssh.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://openssh:openssh.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..d8a087e43 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://openssh:openssh.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://openssh:openssh.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/vex.openvex.json new file mode 100644 index 000000000..ddfc7ccb9 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2024-6387", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..dfb9f68e8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/openssh-CVE-2024-6387-regreSSHion:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..9a60bc46f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://openssh:openssh.c#entry" + }, + { + "sid": "sym://openssh:openssh.c#sink" + } + ], + "edges": [ + { + "from": "sym://openssh:openssh.c#entry", + "to": "sym://openssh:openssh.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/manifest.json new file mode 100644 index 000000000..352503ca8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/openssh-CVE-2024-6387-regreSSHion:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..29d415030 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://openssh:openssh.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://openssh:openssh.c#entry", + "sym://openssh:openssh.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/symbols.json new file mode 100644 index 000000000..2f060b67b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/openssh@0.0.1", + "files": [ + { + "path": "/src/openssh.c", + "funcs": [ + { + "sid": "sym://openssh:openssh.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://openssh:openssh.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..8fcccaa0d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://openssh:openssh.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..9fcfef671 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssh-CVE-2024-6387-regreSSHion/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2024-6387", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/case.json new file mode 100644 index 000000000..28a363708 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/case.json @@ -0,0 +1,46 @@ +{ + "id": "openssl-CVE-2022-3602-x509-name-constraints", + "cve": "CVE-2022-3602", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://openssl:openssl.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://openssl:openssl.c#entry", + "sym://openssl:openssl.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://openssl:openssl.c#entry -> sym://openssl:openssl.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/docs/README.md new file mode 100644 index 000000000..d8ea1d060 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/docs/README.md @@ -0,0 +1,15 @@ +# openssl-CVE-2022-3602-x509-name-constraints +Primary axis: binary-hybrid +Tags: x509, parser, stack-overflow +Languages: c + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..444ceb36a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/openssl-CVE-2022-3602-x509-name-constraints:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.static.json new file mode 100644 index 000000000..2d20aff47 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://openssl:openssl.c#entry" + }, + { + "sid": "sym://openssl:openssl.c#sink" + } + ], + "edges": [ + { + "from": "sym://openssl:openssl.c#entry", + "to": "sym://openssl:openssl.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/manifest.json new file mode 100644 index 000000000..45105ba87 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/openssl-CVE-2022-3602-x509-name-constraints:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..80f286117 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://openssl:openssl.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://openssl:openssl.c#entry", + "sym://openssl:openssl.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/symbols.json new file mode 100644 index 000000000..02d8ef9ce --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/openssl@0.0.1", + "files": [ + { + "path": "/src/openssl.c", + "funcs": [ + { + "sid": "sym://openssl:openssl.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://openssl:openssl.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..95a59815a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://openssl:openssl.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://openssl:openssl.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/vex.openvex.json new file mode 100644 index 000000000..b6b9a91a0 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-3602", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..aec32695f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/openssl-CVE-2022-3602-x509-name-constraints:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..2d20aff47 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://openssl:openssl.c#entry" + }, + { + "sid": "sym://openssl:openssl.c#sink" + } + ], + "edges": [ + { + "from": "sym://openssl:openssl.c#entry", + "to": "sym://openssl:openssl.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/manifest.json new file mode 100644 index 000000000..894af1762 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/openssl-CVE-2022-3602-x509-name-constraints:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..80f286117 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://openssl:openssl.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://openssl:openssl.c#entry", + "sym://openssl:openssl.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/symbols.json new file mode 100644 index 000000000..02d8ef9ce --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/openssl@0.0.1", + "files": [ + { + "path": "/src/openssl.c", + "funcs": [ + { + "sid": "sym://openssl:openssl.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://openssl:openssl.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..eebaf299d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://openssl:openssl.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..718c169db --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/openssl-CVE-2022-3602-x509-name-constraints/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-3602", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/case.json new file mode 100644 index 000000000..c8e1e7126 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/case.json @@ -0,0 +1,46 @@ +{ + "id": "php-phpmailer-CVE-2016-10033-rce", + "cve": "CVE-2016-10033", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://php:php.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://php:php.c#entry", + "sym://php:php.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://php:php.c#entry -> sym://php:php.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/docs/README.md new file mode 100644 index 000000000..9061186b8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/docs/README.md @@ -0,0 +1,15 @@ +# php-phpmailer-CVE-2016-10033-rce +Primary axis: lang-php +Tags: rce, email +Languages: php + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..e618142f7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/php-phpmailer-CVE-2016-10033-rce:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.static.json new file mode 100644 index 000000000..dd26df0a5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://php:php.c#entry" + }, + { + "sid": "sym://php:php.c#sink" + } + ], + "edges": [ + { + "from": "sym://php:php.c#entry", + "to": "sym://php:php.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/manifest.json new file mode 100644 index 000000000..19a1ffb51 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/php-phpmailer-CVE-2016-10033-rce:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..c3e890b27 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://php:php.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://php:php.c#entry", + "sym://php:php.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/symbols.json new file mode 100644 index 000000000..b8a19e77d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/php@0.0.1", + "files": [ + { + "path": "/src/php.c", + "funcs": [ + { + "sid": "sym://php:php.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://php:php.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..5c338e5bc --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://php:php.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://php:php.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/vex.openvex.json new file mode 100644 index 000000000..06d6fa6e7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2016-10033", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..8138e6f69 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/php-phpmailer-CVE-2016-10033-rce:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..dd26df0a5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://php:php.c#entry" + }, + { + "sid": "sym://php:php.c#sink" + } + ], + "edges": [ + { + "from": "sym://php:php.c#entry", + "to": "sym://php:php.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/manifest.json new file mode 100644 index 000000000..af22bcd6d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/php-phpmailer-CVE-2016-10033-rce:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..c3e890b27 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://php:php.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://php:php.c#entry", + "sym://php:php.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/symbols.json new file mode 100644 index 000000000..b8a19e77d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/php@0.0.1", + "files": [ + { + "path": "/src/php.c", + "funcs": [ + { + "sid": "sym://php:php.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://php:php.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..2bd8627ba --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://php:php.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..271b2d42e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/php-phpmailer-CVE-2016-10033-rce/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2016-10033", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/case.json new file mode 100644 index 000000000..6d995794f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/case.json @@ -0,0 +1,46 @@ +{ + "id": "python-django-CVE-2019-19844-sqli-like", + "cve": "CVE-2019-19844", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://python:python.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://python:python.c#entry -> sym://python:python.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/docs/README.md new file mode 100644 index 000000000..2ce64e1e3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/docs/README.md @@ -0,0 +1,15 @@ +# python-django-CVE-2019-19844-sqli-like +Primary axis: lang-python +Tags: sqli, orm +Languages: python + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..8bbd6878a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/python-django-CVE-2019-19844-sqli-like:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.static.json new file mode 100644 index 000000000..957504c2c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://python:python.c#entry" + }, + { + "sid": "sym://python:python.c#sink" + } + ], + "edges": [ + { + "from": "sym://python:python.c#entry", + "to": "sym://python:python.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/manifest.json new file mode 100644 index 000000000..a69b2391c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/python-django-CVE-2019-19844-sqli-like:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..8c105ee3a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://python:python.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/symbols.json new file mode 100644 index 000000000..fe54a5e55 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/python@0.0.1", + "files": [ + { + "path": "/src/python.c", + "funcs": [ + { + "sid": "sym://python:python.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://python:python.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..3f482f7b6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://python:python.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://python:python.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/vex.openvex.json new file mode 100644 index 000000000..6f9ef32fb --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-19844", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..b3d02d610 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/python-django-CVE-2019-19844-sqli-like:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..957504c2c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://python:python.c#entry" + }, + { + "sid": "sym://python:python.c#sink" + } + ], + "edges": [ + { + "from": "sym://python:python.c#entry", + "to": "sym://python:python.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/manifest.json new file mode 100644 index 000000000..ef132a4ca --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/python-django-CVE-2019-19844-sqli-like:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..8c105ee3a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://python:python.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/symbols.json new file mode 100644 index 000000000..fe54a5e55 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/python@0.0.1", + "files": [ + { + "path": "/src/python.c", + "funcs": [ + { + "sid": "sym://python:python.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://python:python.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..6c8ba6295 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://python:python.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..acecc6979 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-django-CVE-2019-19844-sqli-like/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-19844", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/case.json new file mode 100644 index 000000000..b8e824bb2 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/case.json @@ -0,0 +1,46 @@ +{ + "id": "python-jinja2-CVE-2019-10906-template-injection", + "cve": "CVE-2019-10906", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://python:python.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://python:python.c#entry -> sym://python:python.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/docs/README.md new file mode 100644 index 000000000..098f0a9c4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/docs/README.md @@ -0,0 +1,15 @@ +# python-jinja2-CVE-2019-10906-template-injection +Primary axis: lang-python +Tags: template-injection +Languages: python + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..91e11f3ba --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/python-jinja2-CVE-2019-10906-template-injection:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.static.json new file mode 100644 index 000000000..957504c2c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://python:python.c#entry" + }, + { + "sid": "sym://python:python.c#sink" + } + ], + "edges": [ + { + "from": "sym://python:python.c#entry", + "to": "sym://python:python.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/manifest.json new file mode 100644 index 000000000..42a9193f3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/python-jinja2-CVE-2019-10906-template-injection:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..8c105ee3a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://python:python.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/symbols.json new file mode 100644 index 000000000..fe54a5e55 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/python@0.0.1", + "files": [ + { + "path": "/src/python.c", + "funcs": [ + { + "sid": "sym://python:python.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://python:python.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..3f482f7b6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://python:python.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://python:python.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/vex.openvex.json new file mode 100644 index 000000000..043b45cf9 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-10906", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..0e6890217 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/python-jinja2-CVE-2019-10906-template-injection:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..957504c2c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://python:python.c#entry" + }, + { + "sid": "sym://python:python.c#sink" + } + ], + "edges": [ + { + "from": "sym://python:python.c#entry", + "to": "sym://python:python.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/manifest.json new file mode 100644 index 000000000..f023d4c01 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/python-jinja2-CVE-2019-10906-template-injection:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..8c105ee3a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://python:python.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/symbols.json new file mode 100644 index 000000000..fe54a5e55 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/python@0.0.1", + "files": [ + { + "path": "/src/python.c", + "funcs": [ + { + "sid": "sym://python:python.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://python:python.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..6c8ba6295 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://python:python.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..2bbb6c0bf --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-jinja2-CVE-2019-10906-template-injection/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-10906", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/case.json new file mode 100644 index 000000000..f107490eb --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/case.json @@ -0,0 +1,46 @@ +{ + "id": "python-urllib3-dos-regex-TBD", + "cve": "N/A", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://python:python.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://python:python.c#entry -> sym://python:python.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/docs/README.md new file mode 100644 index 000000000..fb017474b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/docs/README.md @@ -0,0 +1,15 @@ +# python-urllib3-dos-regex-TBD +Primary axis: lang-python +Tags: regex-dos, parser +Languages: python + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..dfd1c3f62 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/python-urllib3-dos-regex-TBD:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.static.json new file mode 100644 index 000000000..957504c2c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://python:python.c#entry" + }, + { + "sid": "sym://python:python.c#sink" + } + ], + "edges": [ + { + "from": "sym://python:python.c#entry", + "to": "sym://python:python.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/manifest.json new file mode 100644 index 000000000..7549f42d6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/python-urllib3-dos-regex-TBD:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..8c105ee3a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://python:python.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/symbols.json new file mode 100644 index 000000000..fe54a5e55 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/python@0.0.1", + "files": [ + { + "path": "/src/python.c", + "funcs": [ + { + "sid": "sym://python:python.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://python:python.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..3f482f7b6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://python:python.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://python:python.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/vex.openvex.json new file mode 100644 index 000000000..a9c299cc5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..5d409bf13 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/python-urllib3-dos-regex-TBD:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..957504c2c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://python:python.c#entry" + }, + { + "sid": "sym://python:python.c#sink" + } + ], + "edges": [ + { + "from": "sym://python:python.c#entry", + "to": "sym://python:python.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/manifest.json new file mode 100644 index 000000000..bd79cc931 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/python-urllib3-dos-regex-TBD:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..8c105ee3a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://python:python.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://python:python.c#entry", + "sym://python:python.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/symbols.json new file mode 100644 index 000000000..fe54a5e55 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/python@0.0.1", + "files": [ + { + "path": "/src/python.c", + "funcs": [ + { + "sid": "sym://python:python.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://python:python.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..6c8ba6295 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://python:python.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..eb7c35e0b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/python-urllib3-dos-regex-TBD/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/case.json new file mode 100644 index 000000000..f8e0728c7 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/case.json @@ -0,0 +1,46 @@ +{ + "id": "rails-CVE-2019-5418-file-content-disclosure", + "cve": "CVE-2019-5418", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://rails:rails.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://rails:rails.c#entry", + "sym://rails:rails.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://rails:rails.c#entry -> sym://rails:rails.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/docs/README.md new file mode 100644 index 000000000..9e3222639 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/docs/README.md @@ -0,0 +1,15 @@ +# rails-CVE-2019-5418-file-content-disclosure +Primary axis: lang-ruby +Tags: path-traversal, mime +Languages: ruby + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..123c314f1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/rails-CVE-2019-5418-file-content-disclosure:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.static.json new file mode 100644 index 000000000..37c19b1a4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://rails:rails.c#entry" + }, + { + "sid": "sym://rails:rails.c#sink" + } + ], + "edges": [ + { + "from": "sym://rails:rails.c#entry", + "to": "sym://rails:rails.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/manifest.json new file mode 100644 index 000000000..15050a05b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/rails-CVE-2019-5418-file-content-disclosure:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..b4d7d8086 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://rails:rails.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://rails:rails.c#entry", + "sym://rails:rails.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/symbols.json new file mode 100644 index 000000000..b908b06d3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/rails@0.0.1", + "files": [ + { + "path": "/src/rails.c", + "funcs": [ + { + "sid": "sym://rails:rails.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://rails:rails.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..2727af04d --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://rails:rails.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://rails:rails.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/vex.openvex.json new file mode 100644 index 000000000..c9995f83e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-5418", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..d4738f475 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/rails-CVE-2019-5418-file-content-disclosure:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..37c19b1a4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://rails:rails.c#entry" + }, + { + "sid": "sym://rails:rails.c#sink" + } + ], + "edges": [ + { + "from": "sym://rails:rails.c#entry", + "to": "sym://rails:rails.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/manifest.json new file mode 100644 index 000000000..de8baafdb --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/rails-CVE-2019-5418-file-content-disclosure:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..b4d7d8086 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://rails:rails.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://rails:rails.c#entry", + "sym://rails:rails.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/symbols.json new file mode 100644 index 000000000..b908b06d3 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/rails@0.0.1", + "files": [ + { + "path": "/src/rails.c", + "funcs": [ + { + "sid": "sym://rails:rails.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://rails:rails.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..116bba17a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://rails:rails.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..ddc1b2f3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rails-CVE-2019-5418-file-content-disclosure/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2019-5418", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/case.json new file mode 100644 index 000000000..f1a1aac84 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/case.json @@ -0,0 +1,46 @@ +{ + "id": "redis-CVE-2022-0543-lua-sandbox-escape", + "cve": "CVE-2022-0543", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://redis:redis.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://redis:redis.c#entry", + "sym://redis:redis.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://redis:redis.c#entry -> sym://redis:redis.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/docs/README.md new file mode 100644 index 000000000..48ad89fc5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/docs/README.md @@ -0,0 +1,15 @@ +# redis-CVE-2022-0543-lua-sandbox-escape +Primary axis: binary-hybrid +Tags: lua, sandbox, rce +Languages: c, lua + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..43a512c8b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/redis-CVE-2022-0543-lua-sandbox-escape:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.static.json new file mode 100644 index 000000000..2b5732497 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://redis:redis.c#entry" + }, + { + "sid": "sym://redis:redis.c#sink" + } + ], + "edges": [ + { + "from": "sym://redis:redis.c#entry", + "to": "sym://redis:redis.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/manifest.json new file mode 100644 index 000000000..bfca928cd --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/redis-CVE-2022-0543-lua-sandbox-escape:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..8c67704fa --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://redis:redis.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://redis:redis.c#entry", + "sym://redis:redis.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/symbols.json new file mode 100644 index 000000000..8bf798514 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/redis@0.0.1", + "files": [ + { + "path": "/src/redis.c", + "funcs": [ + { + "sid": "sym://redis:redis.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://redis:redis.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..2c2f88e19 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://redis:redis.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://redis:redis.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/vex.openvex.json new file mode 100644 index 000000000..53b717714 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-0543", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..3f219e041 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/redis-CVE-2022-0543-lua-sandbox-escape:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..2b5732497 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://redis:redis.c#entry" + }, + { + "sid": "sym://redis:redis.c#sink" + } + ], + "edges": [ + { + "from": "sym://redis:redis.c#entry", + "to": "sym://redis:redis.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/manifest.json new file mode 100644 index 000000000..9eb44f00c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/redis-CVE-2022-0543-lua-sandbox-escape:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..8c67704fa --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://redis:redis.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://redis:redis.c#entry", + "sym://redis:redis.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/symbols.json new file mode 100644 index 000000000..8bf798514 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/redis@0.0.1", + "files": [ + { + "path": "/src/redis.c", + "funcs": [ + { + "sid": "sym://redis:redis.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://redis:redis.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..10c1dad59 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://redis:redis.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..33a312d55 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/redis-CVE-2022-0543-lua-sandbox-escape/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-0543", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/case.json new file mode 100644 index 000000000..1e5ec5f3f --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/case.json @@ -0,0 +1,46 @@ +{ + "id": "runc-CVE-2024-21626-symlink-breakout", + "cve": "CVE-2024-21626", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://runc:runc.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://runc:runc.c#entry", + "sym://runc:runc.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://runc:runc.c#entry -> sym://runc:runc.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/docs/README.md new file mode 100644 index 000000000..8f306e28e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/docs/README.md @@ -0,0 +1,15 @@ +# runc-CVE-2024-21626-symlink-breakout +Primary axis: container-escape +Tags: symlink, filesystem, userns +Languages: binary + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..d41c01ae1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/runc-CVE-2024-21626-symlink-breakout:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.static.json new file mode 100644 index 000000000..e4f83283b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://runc:runc.c#entry" + }, + { + "sid": "sym://runc:runc.c#sink" + } + ], + "edges": [ + { + "from": "sym://runc:runc.c#entry", + "to": "sym://runc:runc.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/manifest.json new file mode 100644 index 000000000..c7a716d5b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/runc-CVE-2024-21626-symlink-breakout:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..50bd676e6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://runc:runc.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://runc:runc.c#entry", + "sym://runc:runc.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/symbols.json new file mode 100644 index 000000000..a4c878da5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/runc@0.0.1", + "files": [ + { + "path": "/src/runc.c", + "funcs": [ + { + "sid": "sym://runc:runc.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://runc:runc.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..724a3a1d1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://runc:runc.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://runc:runc.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/vex.openvex.json new file mode 100644 index 000000000..cb6b4a439 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2024-21626", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..c8b6b4125 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/runc-CVE-2024-21626-symlink-breakout:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..e4f83283b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://runc:runc.c#entry" + }, + { + "sid": "sym://runc:runc.c#sink" + } + ], + "edges": [ + { + "from": "sym://runc:runc.c#entry", + "to": "sym://runc:runc.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/manifest.json new file mode 100644 index 000000000..6765246d1 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/runc-CVE-2024-21626-symlink-breakout:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..50bd676e6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://runc:runc.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://runc:runc.c#entry", + "sym://runc:runc.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/symbols.json new file mode 100644 index 000000000..a4c878da5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/runc@0.0.1", + "files": [ + { + "path": "/src/runc.c", + "funcs": [ + { + "sid": "sym://runc:runc.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://runc:runc.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..7b4a2d113 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://runc:runc.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..9c2943a5c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/runc-CVE-2024-21626-symlink-breakout/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2024-21626", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/case.json new file mode 100644 index 000000000..5ecd20b80 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/case.json @@ -0,0 +1,46 @@ +{ + "id": "rust-axum-header-parsing-TBD", + "cve": "N/A", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://rust:rust.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://rust:rust.c#entry", + "sym://rust:rust.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://rust:rust.c#entry -> sym://rust:rust.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/docs/README.md new file mode 100644 index 000000000..38cc6e3ad --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/docs/README.md @@ -0,0 +1,15 @@ +# rust-axum-header-parsing-TBD +Primary axis: lang-rust +Tags: parser, config-sensitive +Languages: rust + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..06a4d7fef --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/rust-axum-header-parsing-TBD:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.static.json new file mode 100644 index 000000000..6ff0ebcba --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://rust:rust.c#entry" + }, + { + "sid": "sym://rust:rust.c#sink" + } + ], + "edges": [ + { + "from": "sym://rust:rust.c#entry", + "to": "sym://rust:rust.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/manifest.json new file mode 100644 index 000000000..42c557076 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/rust-axum-header-parsing-TBD:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..a91a2942a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://rust:rust.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://rust:rust.c#entry", + "sym://rust:rust.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/symbols.json new file mode 100644 index 000000000..5526dfdd4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/rust@0.0.1", + "files": [ + { + "path": "/src/rust.c", + "funcs": [ + { + "sid": "sym://rust:rust.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://rust:rust.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..a73e5fe92 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://rust:rust.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://rust:rust.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/vex.openvex.json new file mode 100644 index 000000000..a9c299cc5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..de845408e --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/rust-axum-header-parsing-TBD:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..6ff0ebcba --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://rust:rust.c#entry" + }, + { + "sid": "sym://rust:rust.c#sink" + } + ], + "edges": [ + { + "from": "sym://rust:rust.c#entry", + "to": "sym://rust:rust.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/manifest.json new file mode 100644 index 000000000..39f4e8754 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/rust-axum-header-parsing-TBD:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..a91a2942a --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://rust:rust.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://rust:rust.c#entry", + "sym://rust:rust.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/symbols.json new file mode 100644 index 000000000..5526dfdd4 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/rust@0.0.1", + "files": [ + { + "path": "/src/rust.c", + "funcs": [ + { + "sid": "sym://rust:rust.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://rust:rust.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..fde23ce2c --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://rust:rust.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..eb7c35e0b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/rust-axum-header-parsing-TBD/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "TBD", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/case.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/case.json new file mode 100644 index 000000000..f42126052 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/case.json @@ -0,0 +1,46 @@ +{ + "id": "wordpress-core-CVE-2022-21661-sqli", + "cve": "CVE-2022-21661", + "description": "STUB: Replace with accurate description and threat model for the specific CVE/case.", + "threat_model": { + "entry_points": [ + "STUB: define concrete inputs" + ], + "preconditions": [ + "STUB: feature flags / modules / protocols enabled" + ], + "privilege_boundary": [ + "STUB: describe boundary (if any)" + ] + }, + "ground_truth": { + "reachable_variant": { + "status": "affected", + "evidence": { + "symbols": [ + "sym://wordpress:wordpress.c#sink" + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://wordpress:wordpress.c#entry", + "sym://wordpress:wordpress.c#sink" + ] + ], + "runtime_proof": "traces.runtime.jsonl: lines 1-5" + } + }, + "unreachable_variant": { + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "evidence": { + "pruning_reason": [ + "STUB: feature disabled, module absent, or policy denies" + ], + "blocked_edges": [ + "sym://wordpress:wordpress.c#entry -> sym://wordpress:wordpress.c#sink" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/docs/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/docs/README.md new file mode 100644 index 000000000..0f3935aed --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/docs/README.md @@ -0,0 +1,15 @@ +# wordpress-core-CVE-2022-21661-sqli +Primary axis: lang-php +Tags: sqli, core +Languages: php + +## Variants +- reachable: vulnerable function/path is on an executable route. +- unreachable: same base image/config with control toggles that prune the path. + +## Entrypoint & Controls (fill in) +- entrypoints: e.g., http:/route, grpc method, tcp port, OCI hook +- flags: e.g., feature_on=true, middleware_order=bad|good, module_loaded=true|false, LSM=enforcing|permissive + +## Expected ground-truth path(s) +See `images/*/reachgraph.truth.json`. diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/attestation.dsse.json new file mode 100644 index 000000000..2a480dcd2 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/wordpress-core-CVE-2022-21661-sqli:reachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.static.json new file mode 100644 index 000000000..fd271fd64 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://wordpress:wordpress.c#entry" + }, + { + "sid": "sym://wordpress:wordpress.c#sink" + } + ], + "edges": [ + { + "from": "sym://wordpress:wordpress.c#entry", + "to": "sym://wordpress:wordpress.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/manifest.json new file mode 100644 index 000000000..825fd9cf2 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/wordpress-core-CVE-2022-21661-sqli:reachable", + "config_flags": { + "FEATURE_FLAG": true, + "POLICY_MODE": "permissive" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/reachgraph.truth.json new file mode 100644 index 000000000..0a162f4f8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://wordpress:wordpress.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://wordpress:wordpress.c#entry", + "sym://wordpress:wordpress.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/symbols.json new file mode 100644 index 000000000..2d9c7dfb6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/wordpress@0.0.1", + "files": [ + { + "path": "/src/wordpress.c", + "funcs": [ + { + "sid": "sym://wordpress:wordpress.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://wordpress:wordpress.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/traces.runtime.jsonl new file mode 100644 index 000000000..31d2564de --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/traces.runtime.jsonl @@ -0,0 +1,2 @@ +{"ts": 1.001, "event": "call", "sid": "sym://wordpress:wordpress.c#entry", "pid": 100} +{"ts": 1.005, "event": "call", "sid": "sym://wordpress:wordpress.c#sink", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/vex.openvex.json new file mode 100644 index 000000000..cd3ab5743 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/reachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-21661", + "status": "affected", + "justification": "reasoning_provided", + "impact_statement": "Function-level path is reachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/attestation.dsse.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/attestation.dsse.json new file mode 100644 index 000000000..f830e9773 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/attestation.dsse.json @@ -0,0 +1,30 @@ +{ + "dsse_version": "1.0", + "subject": [ + { + "name": "ghcr.io/reachbench/wordpress-core-CVE-2022-21661-sqli:unreachable", + "digest": { + "sha256": "STUB_DIGEST" + } + } + ], + "statement": { + "type": "reachbench.attestation", + "materials": [ + "sbom.cdx.json", + "sbom.spdx.json", + "symbols.json", + "callgraph.static.json", + "callgraph.framework.json", + "reachgraph.truth.json", + "vex.openvex.json" + ] + }, + "signatures": [ + { + "keyid": "STUB", + "sig": "STUB_SIGNATURE", + "alg": "dilithium2" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.framework.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.framework.json new file mode 100644 index 000000000..299d7dd3b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.framework.json @@ -0,0 +1,4 @@ +{ + "schema_version": "1.0", + "edges": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.static.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.static.json new file mode 100644 index 000000000..fd271fd64 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/callgraph.static.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "nodes": [ + { + "sid": "sym://wordpress:wordpress.c#entry" + }, + { + "sid": "sym://wordpress:wordpress.c#sink" + } + ], + "edges": [ + { + "from": "sym://wordpress:wordpress.c#entry", + "to": "sym://wordpress:wordpress.c#sink", + "kind": "direct" + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/manifest.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/manifest.json new file mode 100644 index 000000000..d5d89b9a5 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/manifest.json @@ -0,0 +1,8 @@ +{ + "image": "ghcr.io/reachbench/wordpress-core-CVE-2022-21661-sqli:unreachable", + "config_flags": { + "FEATURE_FLAG": false, + "POLICY_MODE": "enforcing" + }, + "sha256": "STUB_DIGEST" +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/reachgraph.truth.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/reachgraph.truth.json new file mode 100644 index 000000000..0a162f4f8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/reachgraph.truth.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "sinks": [ + { + "sid": "sym://wordpress:wordpress.c#sink", + "kind": "generic" + } + ], + "paths": [ + [ + "sym://net:handler#read", + "sym://wordpress:wordpress.c#entry", + "sym://wordpress:wordpress.c#sink" + ] + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.cdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.cdx.json new file mode 100644 index 000000000..42913d53b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.cdx.json @@ -0,0 +1,5 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.spdx.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.spdx.json new file mode 100644 index 000000000..38e10e06b --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/sbom.spdx.json @@ -0,0 +1,6 @@ +{ + "spdxVersion": "SPDX-3.0", + "creationInfo": { + "created": "2025-11-07T22:40:04Z" + } +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/symbols.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/symbols.json new file mode 100644 index 000000000..2d9c7dfb6 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/symbols.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "components": [ + { + "purl": "pkg:generic/wordpress@0.0.1", + "files": [ + { + "path": "/src/wordpress.c", + "funcs": [ + { + "sid": "sym://wordpress:wordpress.c#entry", + "name": "entry", + "range": { + "start": 10, + "end": 20 + } + }, + { + "sid": "sym://wordpress:wordpress.c#sink", + "name": "sink", + "range": { + "start": 30, + "end": 60 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/traces.runtime.jsonl b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/traces.runtime.jsonl new file mode 100644 index 000000000..5f3f086c8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/traces.runtime.jsonl @@ -0,0 +1 @@ +{"ts": 1.001, "event": "call", "sid": "sym://wordpress:wordpress.c#entry", "pid": 100} diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/vex.openvex.json b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/vex.openvex.json new file mode 100644 index 000000000..6c542c416 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/cases/wordpress-core-CVE-2022-21661-sqli/images/unreachable/vex.openvex.json @@ -0,0 +1,12 @@ +{ + "author": "reachbench-2025", + "timestamp": "2025-11-07T22:40:04Z", + "statements": [ + { + "vulnerability": "CVE-2022-21661", + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Pruned by configuration; path unreachable." + } + ] +} \ No newline at end of file diff --git a/tests/reachability/fixtures/reachbench-2025-expanded/harness/evaluator/README.md b/tests/reachability/fixtures/reachbench-2025-expanded/harness/evaluator/README.md new file mode 100644 index 000000000..6d431c9c8 --- /dev/null +++ b/tests/reachability/fixtures/reachbench-2025-expanded/harness/evaluator/README.md @@ -0,0 +1 @@ +Evaluator remains identical to the starter; plug your scanner output into results///report.json and run your scoring.