From 00d2c99af94dfd42dd599b66285e3926b38a4d4f Mon Sep 17 00:00:00 2001 From: master <> Date: Thu, 18 Dec 2025 13:15:13 +0200 Subject: [PATCH] feat: add Attestation Chain and Triage Evidence API clients and models - Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains. - Created models for Attestation Chain, including DSSE envelope structures and verification results. - Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component. - Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence. - Introduced mock implementations for both API clients to facilitate testing and development. --- docs/contracts/witness-v1.md | 221 +++++ ...signals_callgraph_projection_completion.md | 16 +- ...T_3410_0001_0001_epss_ingestion_storage.md | 40 +- .../SPRINT_3500_0010_0001_pe_full_parser.md | 40 +- ...SPRINT_3500_0010_0002_macho_full_parser.md | 60 +- ...NT_3500_0011_0001_buildid_mapping_index.md | 30 +- ...620_0001_0001_reachability_witness_dsse.md | 24 +- .../SPRINT_3700_0001_0001_triage_db_schema.md | 27 +- ...PRINT_3700_0001_0001_witness_foundation.md | 58 +- ...RINT_3800_0001_0001_evidence_api_models.md | 20 +- ...800_0001_0002_score_explanation_service.md | 24 +- ...T_0340_0001_0001_scanner_offline_config.md | 0 ...RINT_0341_0001_0001_observability_audit.md | 0 ...SPRINT_0341_0001_0001_ttfs_enhancements.md | 0 ...200_001_000_router_rate_limiting_master.md | 0 ..._1200_001_001_router_rate_limiting_core.md | 0 ..._001_002_router_rate_limiting_per_route.md | 0 ..._003_router_rate_limiting_rule_stacking.md | 0 ..._router_rate_limiting_service_migration.md | 0 ...1200_001_005_router_rate_limiting_tests.md | 0 ..._1200_001_006_router_rate_limiting_docs.md | 0 .../SPRINT_1200_001_IMPLEMENTATION_GUIDE.md | 0 .../{ => archived}/SPRINT_1200_001_README.md | 0 ...1_0001_scanner_api_ingestion_completion.md | 0 ...signals_callgraph_projection_completion.md | 60 ++ ...401_0002_0001_score_replay_proof_bundle.md | 0 ...20_0001_0001_bitemporal_unknowns_schema.md | 0 .../SPRINT_3421_0001_0001_rls_expansion.md | 0 ...SPRINT_3423_0001_0001_generated_columns.md | 0 ...NT_3500_0002_0001_smart_diff_foundation.md | 0 ...INT_3500_0003_0001_smart_diff_detection.md | 2 +- ...600_0002_0001_call_graph_infrastructure.md | 0 ...T_3600_0003_0001_drift_detection_engine.md | 0 ...T_3602_0001_0001_evidence_decision_apis.md | 0 ...NT_3603_0001_0001_offline_bundle_format.md | 0 docs/modules/scanner/epss-integration.md | 80 +- .../Index/BuildIdIndexEntry.cs | 65 ++ .../Index/BuildIdIndexOptions.cs | 38 + .../Index/BuildIdLookupResult.cs | 39 + .../Index/IBuildIdIndex.cs | 42 + .../Index/OfflineBuildIdIndex.cs | 207 +++++ .../MachOCodeSignature.cs | 16 + .../MachOIdentity.cs | 24 + .../MachOPlatform.cs | 46 ++ .../MachOReader.cs | 640 +++++++++++++++ .../NativeBinaryIdentity.cs | 29 +- .../NativeFormatDetector.cs | 46 +- .../PeCompilerHint.cs | 12 + .../PeIdentity.cs | 34 + .../PeReader.cs | 757 ++++++++++++++++++ .../Contracts/FindingEvidenceContracts.cs | 451 +++++++++++ .../Endpoints/WitnessEndpoints.cs | 251 ++++++ .../StellaOps.Scanner.WebService/Program.cs | 1 + .../Processing/EpssIngestJob.cs | 272 +++++++ .../StellaOps.Scanner.Worker/Program.cs | 6 + .../Native/INativeComponentEmitter.cs | 44 + .../Native/NativeBinaryMetadata.cs | 55 ++ .../Native/NativeComponentEmitter.cs | 155 ++++ .../Native/NativePurlBuilder.cs | 115 +++ .../StellaOps.Scanner.Emit.csproj | 1 + .../IReachabilityWitnessPublisher.cs | 44 + .../ReachabilityWitnessDsseBuilder.cs | 207 +++++ .../Attestation/ReachabilityWitnessOptions.cs | 45 ++ .../ReachabilityWitnessPublisher.cs | 147 ++++ .../ReachabilityWitnessStatement.cs | 66 ++ .../Witnesses/IPathWitnessBuilder.cs | 175 ++++ .../Witnesses/PathWitness.cs | 256 ++++++ .../Witnesses/PathWitnessBuilder.cs | 378 +++++++++ .../Witnesses/WitnessSchema.cs | 22 + .../Detection/BoundaryProof.cs | 216 +++++ .../Detection/VexEvidence.cs | 179 +++++ .../Epss/Events/EpssUpdatedEvent.cs | 195 +++++ .../Extensions/ServiceCollectionExtensions.cs | 9 + .../Migrations/013_witness_storage.sql | 60 ++ .../Postgres/Migrations/MigrationIds.cs | 3 + .../Repositories/IWitnessRepository.cs | 89 ++ .../Repositories/PostgresWitnessRepository.cs | 275 +++++++ .../Entities/TriageCaseCurrent.cs | 162 ++++ .../Entities/TriageDecision.cs | 120 +++ .../Entities/TriageEffectiveVex.cs | 91 +++ .../Entities/TriageEnums.cs | 151 ++++ .../Entities/TriageEvidenceArtifact.cs | 103 +++ .../Entities/TriageFinding.cs | 78 ++ .../Entities/TriageReachabilityResult.cs | 66 ++ .../Entities/TriageRiskResult.cs | 87 ++ .../Entities/TriageSnapshot.cs | 66 ++ .../Migrations/V3700_001__triage_schema.sql | 249 ++++++ .../StellaOps.Scanner.Triage.csproj | 16 + .../TriageDbContext.cs | 228 ++++++ .../Index/OfflineBuildIdIndexTests.cs | 281 +++++++ .../MachOReaderTests.cs | 425 ++++++++++ .../PeReaderTests.cs | 361 +++++++++ .../PathWitnessBuilderTests.cs | 387 +++++++++ .../ReachabilityWitnessDsseBuilderTests.cs | 320 ++++++++ .../RichGraphWriterTests.cs | 26 + .../FindingEvidenceContractsTests.cs | 293 +++++++ .../CallGraphProjectionIntegrationTests.cs | 222 +++++ .../PostgresCallGraphProjectionRepository.cs | 466 +++++++++++ .../ServiceCollectionExtensions.cs | 2 + .../Models/ScoreExplanation.cs | 192 +++++ .../Options/ScoreExplanationWeights.cs | 128 +++ .../Options/SignalsScoringOptions.cs | 6 + .../ICallGraphProjectionRepository.cs | 84 ++ .../InMemoryCallGraphProjectionRepository.cs | 156 ++++ src/Signals/StellaOps.Signals/Program.cs | 3 + .../Services/CallGraphSyncService.cs | 118 +++ .../Services/CallgraphIngestionService.cs | 35 + .../Services/ICallGraphSyncService.cs | 59 ++ .../Services/IScoreExplanationService.cs | 92 +++ .../Services/ScoreExplanationService.cs | 315 ++++++++ src/Signals/StellaOps.Signals/TASKS.md | 4 + .../CallGraphSyncServiceTests.cs | 271 +++++++ .../CallgraphIngestionServiceTests.cs | 31 + .../ScoreExplanationServiceTests.cs | 287 +++++++ .../app/core/api/attestation-chain.client.ts | 312 ++++++++ .../app/core/api/attestation-chain.models.ts | 291 +++++++ .../app/core/api/triage-evidence.client.ts | 351 ++++++++ .../app/core/api/triage-evidence.models.ts | 265 ++++++ 118 files changed, 13463 insertions(+), 151 deletions(-) create mode 100644 docs/contracts/witness-v1.md rename docs/implplan/{ => archived}/SPRINT_0340_0001_0001_scanner_offline_config.md (100%) rename docs/implplan/{ => archived}/SPRINT_0341_0001_0001_observability_audit.md (100%) rename docs/implplan/{ => archived}/SPRINT_0341_0001_0001_ttfs_enhancements.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_000_router_rate_limiting_master.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_001_router_rate_limiting_core.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_002_router_rate_limiting_per_route.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_004_router_rate_limiting_service_migration.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_005_router_rate_limiting_tests.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_006_router_rate_limiting_docs.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_IMPLEMENTATION_GUIDE.md (100%) rename docs/implplan/{ => archived}/SPRINT_1200_001_README.md (100%) rename docs/implplan/{ => archived}/SPRINT_3103_0001_0001_scanner_api_ingestion_completion.md (100%) create mode 100644 docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md rename docs/implplan/{ => archived}/SPRINT_3401_0002_0001_score_replay_proof_bundle.md (100%) rename docs/implplan/{ => archived}/SPRINT_3420_0001_0001_bitemporal_unknowns_schema.md (100%) rename docs/implplan/{ => archived}/SPRINT_3421_0001_0001_rls_expansion.md (100%) rename docs/implplan/{ => archived}/SPRINT_3423_0001_0001_generated_columns.md (100%) rename docs/implplan/{ => archived}/SPRINT_3500_0002_0001_smart_diff_foundation.md (100%) rename docs/implplan/{ => archived}/SPRINT_3500_0003_0001_smart_diff_detection.md (99%) rename docs/implplan/{ => archived}/SPRINT_3600_0002_0001_call_graph_infrastructure.md (100%) rename docs/implplan/{ => archived}/SPRINT_3600_0003_0001_drift_detection_engine.md (100%) rename docs/implplan/{ => archived}/SPRINT_3602_0001_0001_evidence_decision_apis.md (100%) rename docs/implplan/{ => archived}/SPRINT_3603_0001_0001_offline_bundle_format.md (100%) create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexEntry.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexOptions.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdLookupResult.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/IBuildIdIndex.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/OfflineBuildIdIndex.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOCodeSignature.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOIdentity.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOPlatform.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOReader.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/PeCompilerHint.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/PeIdentity.cs create mode 100644 src/Scanner/StellaOps.Scanner.Analyzers.Native/PeReader.cs create mode 100644 src/Scanner/StellaOps.Scanner.WebService/Contracts/FindingEvidenceContracts.cs create mode 100644 src/Scanner/StellaOps.Scanner.WebService/Endpoints/WitnessEndpoints.cs create mode 100644 src/Scanner/StellaOps.Scanner.Worker/Processing/EpssIngestJob.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/INativeComponentEmitter.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeBinaryMetadata.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeComponentEmitter.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativePurlBuilder.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/IReachabilityWitnessPublisher.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessDsseBuilder.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessOptions.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessPublisher.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessStatement.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/IPathWitnessBuilder.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitness.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitnessBuilder.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessSchema.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/BoundaryProof.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/Events/EpssUpdatedEvent.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/013_witness_storage.sql create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/IWitnessRepository.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageCaseCurrent.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEnums.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/Migrations/V3700_001__triage_schema.sql create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/StellaOps.Scanner.Triage.csproj create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Triage/TriageDbContext.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Index/OfflineBuildIdIndexTests.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/MachOReaderTests.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/PeReaderTests.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/PathWitnessBuilderTests.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/ReachabilityWitnessDsseBuilderTests.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/FindingEvidenceContractsTests.cs create mode 100644 src/Signals/StellaOps.Signals.Storage.Postgres.Tests/CallGraphProjectionIntegrationTests.cs create mode 100644 src/Signals/StellaOps.Signals.Storage.Postgres/Repositories/PostgresCallGraphProjectionRepository.cs create mode 100644 src/Signals/StellaOps.Signals/Models/ScoreExplanation.cs create mode 100644 src/Signals/StellaOps.Signals/Options/ScoreExplanationWeights.cs create mode 100644 src/Signals/StellaOps.Signals/Persistence/ICallGraphProjectionRepository.cs create mode 100644 src/Signals/StellaOps.Signals/Persistence/InMemoryCallGraphProjectionRepository.cs create mode 100644 src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs create mode 100644 src/Signals/StellaOps.Signals/Services/ICallGraphSyncService.cs create mode 100644 src/Signals/StellaOps.Signals/Services/IScoreExplanationService.cs create mode 100644 src/Signals/StellaOps.Signals/Services/ScoreExplanationService.cs create mode 100644 src/Signals/__Tests/StellaOps.Signals.Tests/CallGraphSyncServiceTests.cs create mode 100644 src/Signals/__Tests/StellaOps.Signals.Tests/ScoreExplanationServiceTests.cs create mode 100644 src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/attestation-chain.models.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/triage-evidence.client.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts diff --git a/docs/contracts/witness-v1.md b/docs/contracts/witness-v1.md new file mode 100644 index 00000000..51708080 --- /dev/null +++ b/docs/contracts/witness-v1.md @@ -0,0 +1,221 @@ +# Witness Schema v1 Contract + +> **Version**: `stellaops.witness.v1` +> **Status**: Draft +> **Sprint**: `SPRINT_3700_0001_0001_witness_foundation` + +--- + +## Overview + +A **witness** is a cryptographically-signed proof of a reachability path from an entrypoint to a vulnerable sink. Witnesses provide: + +1. **Auditability** - Proof that a path was found at scan time +2. **Offline verification** - Verify claims without re-running analysis +3. **Provenance** - Links to the source graph and analysis context +4. **Transparency** - Can be published to transparency logs + +--- + +## Schema Definition + +### PathWitness + +```json +{ + "$schema": "https://stellaops.org/schemas/witness-v1.json", + "schema_version": "stellaops.witness.v1", + "witness_id": "uuid", + "witness_hash": "blake3:abcd1234...", + "witness_type": "reachability_path", + "created_at": "2025-12-18T12:00:00Z", + + "provenance": { + "graph_hash": "blake3:efgh5678...", + "scan_id": "uuid", + "run_id": "uuid", + "analyzer_version": "1.0.0", + "analysis_timestamp": "2025-12-18T11:59:00Z" + }, + + "path": { + "entrypoint": { + "fqn": "com.example.MyController.handleRequest", + "kind": "http_handler", + "location": { + "file": "src/main/java/com/example/MyController.java", + "line": 42 + } + }, + "sink": { + "fqn": "org.apache.log4j.Logger.log", + "cve": "CVE-2021-44228", + "package": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" + }, + "steps": [ + { + "index": 0, + "fqn": "com.example.MyController.handleRequest", + "call_site": "MyController.java:45", + "edge_type": "call" + }, + { + "index": 1, + "fqn": "com.example.LoggingService.logMessage", + "call_site": "LoggingService.java:23", + "edge_type": "call" + }, + { + "index": 2, + "fqn": "org.apache.log4j.Logger.log", + "call_site": "Logger.java:156", + "edge_type": "sink" + } + ], + "hop_count": 3 + }, + + "gates": [ + { + "type": "auth_required", + "location": "MyController.java:40", + "description": "Requires authenticated user" + } + ], + + "evidence": { + "graph_fragment_hash": "blake3:ijkl9012...", + "path_hash": "blake3:mnop3456..." + } +} +``` + +--- + +## Field Definitions + +### Root Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `schema_version` | string | Yes | Must be `stellaops.witness.v1` | +| `witness_id` | UUID | Yes | Unique identifier | +| `witness_hash` | string | Yes | BLAKE3 hash of canonical JSON | +| `witness_type` | enum | Yes | `reachability_path`, `gate_proof` | +| `created_at` | ISO8601 | Yes | Witness creation timestamp (UTC) | + +### Provenance + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `graph_hash` | string | Yes | BLAKE3 hash of source rich graph | +| `scan_id` | UUID | No | Scan that produced the graph | +| `run_id` | UUID | No | Analysis run identifier | +| `analyzer_version` | string | Yes | Analyzer version | +| `analysis_timestamp` | ISO8601 | Yes | When analysis was performed | + +### Path + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `entrypoint` | object | Yes | Entry point of the path | +| `sink` | object | Yes | Vulnerable sink at end of path | +| `steps` | array | Yes | Ordered list of path steps | +| `hop_count` | integer | Yes | Number of edges in path | + +### Path Step + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `index` | integer | Yes | Position in path (0-indexed) | +| `fqn` | string | Yes | Fully qualified name of node | +| `call_site` | string | No | Source location of call | +| `edge_type` | enum | Yes | `call`, `virtual`, `static`, `sink` | + +### Gates + +Optional array of protective controls encountered along the path. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | enum | Yes | `auth_required`, `feature_flag`, `admin_only`, `non_default_config` | +| `location` | string | No | Source location of gate | +| `description` | string | No | Human-readable description | + +--- + +## Hash Computation + +The `witness_hash` is computed as: + +1. Serialize the witness to canonical JSON (sorted keys, no whitespace) +2. Exclude `witness_id`, `witness_hash`, and `created_at` fields +3. Compute BLAKE3 hash of the canonical bytes +4. Prefix with `blake3:` and hex-encode + +```csharp +var canonical = JsonSerializer.Serialize(witness, canonicalOptions); +var hash = Blake3.Hasher.Hash(Encoding.UTF8.GetBytes(canonical)); +var witnessHash = $"blake3:{Convert.ToHexString(hash.AsSpan()).ToLowerInvariant()}"; +``` + +--- + +## DSSE Signing + +Witnesses are signed using [DSSE (Dead Simple Signing Envelope)](https://github.com/secure-systems-lab/dsse): + +```json +{ + "payloadType": "application/vnd.stellaops.witness.v1+json", + "payload": "", + "signatures": [ + { + "keyid": "sha256:abcd1234...", + "sig": "" + } + ] +} +``` + +### Verification + +1. Decode the payload from base64url +2. Parse as PathWitness JSON +3. Recompute witness_hash and compare +4. Verify signature against known public key +5. Optionally check transparency log for inclusion + +--- + +## Storage + +Witnesses are stored in `scanner.witnesses` table: + +| Column | Type | Description | +|--------|------|-------------| +| `witness_id` | UUID | Primary key | +| `witness_hash` | TEXT | BLAKE3 hash (unique) | +| `payload_json` | JSONB | Full witness JSON | +| `dsse_envelope` | JSONB | Signed envelope (nullable) | +| `graph_hash` | TEXT | Source graph reference | +| `sink_cve` | TEXT | CVE for quick lookup | + +--- + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/witnesses/{id}` | Get witness by ID | +| `GET` | `/api/v1/witnesses?cve={cve}` | List witnesses for CVE | +| `GET` | `/api/v1/witnesses?scan={scanId}` | List witnesses for scan | +| `POST` | `/api/v1/witnesses/{id}/verify` | Verify witness signature | + +--- + +## Related Documents + +- [Rich Graph Contract](richgraph-v1.md) +- [DSSE Specification](https://github.com/secure-systems-lab/dsse) +- [BLAKE3 Hash Function](https://github.com/BLAKE3-team/BLAKE3) diff --git a/docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md b/docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md index 1dffd6d7..80a9acc0 100644 --- a/docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md +++ b/docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md @@ -1,6 +1,6 @@ # Sprint 3104 · Signals callgraph projection completion -**Status:** TODO +**Status:** DONE **Priority:** P2 - MEDIUM **Module:** Signals **Working directory:** `src/Signals/` @@ -22,11 +22,11 @@ ## Delivery Tracker | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | SIG-CG-3104-001 | TODO | Define contract | Signals · Storage | Define `ICallGraphSyncService` for projecting a canonical callgraph into `signals.*` relational tables. | -| 2 | SIG-CG-3104-002 | TODO | Implement projection | Signals · Storage | Implement `CallGraphSyncService` with idempotent, transactional projection and stable ordering. | -| 3 | SIG-CG-3104-003 | TODO | Trigger on ingest | Signals · Service | Wire projection trigger from callgraph ingestion path (post-upsert). | -| 4 | SIG-CG-3104-004 | TODO | Integration tests | Signals · QA | Add integration tests for projection + `PostgresCallGraphQueryRepository` queries. | -| 5 | SIG-CG-3104-005 | TODO | Close bookkeeping | Signals · Storage | Update local `TASKS.md` and sprint status with evidence. | +| 1 | SIG-CG-3104-001 | DONE | Define contract | Signals · Storage | Define `ICallGraphSyncService` for projecting a canonical callgraph into `signals.*` relational tables. | +| 2 | SIG-CG-3104-002 | DONE | Implement projection | Signals · Storage | Implement `CallGraphSyncService` with idempotent, transactional projection and stable ordering. | +| 3 | SIG-CG-3104-003 | DONE | Trigger on ingest | Signals · Service | Wire projection trigger from callgraph ingestion path (post-upsert). | +| 4 | SIG-CG-3104-004 | DONE | Integration tests | Signals · QA | Add integration tests for projection + `PostgresCallGraphQueryRepository` queries. | +| 5 | SIG-CG-3104-005 | DONE | Close bookkeeping | Signals · Storage | Update local `TASKS.md` and sprint status with evidence. | ## Wave Coordination - Wave A: projection contract + service @@ -52,7 +52,9 @@ | Date (UTC) | Update | Owner | | --- | --- | --- | | 2025-12-18 | Sprint created; awaiting staffing. | Planning | +| 2025-12-18 | Verified existing implementations: ICallGraphSyncService, CallGraphSyncService, PostgresCallGraphProjectionRepository all exist and are wired. Wired SyncAsync call into CallgraphIngestionService post-upsert path. Updated CallgraphIngestionServiceTests with StubCallGraphSyncService. Tasks 1-3 DONE. | Agent | +| 2025-12-18 | Added unit tests (CallGraphSyncServiceTests.cs) and integration tests (CallGraphProjectionIntegrationTests.cs). All tasks DONE. | Agent | ## Next Checkpoints -- 2025-12-18: Projection service skeleton + first passing integration test (if staffed). +- 2025-12-18: Sprint completed. diff --git a/docs/implplan/SPRINT_3410_0001_0001_epss_ingestion_storage.md b/docs/implplan/SPRINT_3410_0001_0001_epss_ingestion_storage.md index c1e4f091..f442d0eb 100644 --- a/docs/implplan/SPRINT_3410_0001_0001_epss_ingestion_storage.md +++ b/docs/implplan/SPRINT_3410_0001_0001_epss_ingestion_storage.md @@ -148,21 +148,21 @@ External Dependencies: | ID | Task | Status | Owner | Est. | Notes | |----|------|--------|-------|------|-------| | **EPSS-3410-001** | Database schema migration | DONE | Agent | 2h | Added `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/008_epss_integration.sql` and `MigrationIds.cs` entry; applied via `AddStartupMigrations`. | -| **EPSS-3410-002** | Create `EpssScoreRow` DTO | DOING | Agent | 1h | Streaming DTO for CSV rows. | -| **EPSS-3410-003** | Implement `IEpssSource` interface | DOING | Agent | 2h | Abstraction for online vs bundle. | -| **EPSS-3410-004** | Implement `EpssOnlineSource` | DOING | Agent | 4h | HTTPS download from FIRST.org (optional; not used in tests). | -| **EPSS-3410-005** | Implement `EpssBundleSource` | DOING | Agent | 3h | Local file read for air-gap. | -| **EPSS-3410-006** | Implement `EpssCsvStreamParser` | DOING | Agent | 6h | Parse CSV, extract comment, validate. | -| **EPSS-3410-007** | Implement `EpssRepository` | DOING | Agent | 8h | Data access layer (Dapper + Npgsql) for import runs + scores/current/changes. | -| **EPSS-3410-008** | Implement `EpssChangeDetector` | DOING | Agent | 4h | Delta computation + flag logic (SQL join + `compute_epss_change_flags`). | -| **EPSS-3410-009** | Implement `EpssIngestJob` | DOING | Agent | 6h | Main job orchestration (Worker hosted service; supports online + bundle). | -| **EPSS-3410-010** | Configure Scheduler job trigger | TODO | Backend | 2h | Add to `scheduler.yaml` | -| **EPSS-3410-011** | Implement outbox event schema | TODO | Backend | 2h | `epss.updated@1` event | -| **EPSS-3410-012** | Unit tests (parser, detector, flags) | TODO | Backend | 6h | xUnit tests | -| **EPSS-3410-013** | Integration tests (Testcontainers) | TODO | Backend | 8h | End-to-end ingestion test | -| **EPSS-3410-014** | Performance test (300k rows) | TODO | Backend | 4h | Verify <120s budget | -| **EPSS-3410-015** | Observability (metrics, logs, traces) | TODO | Backend | 4h | OpenTelemetry integration | -| **EPSS-3410-016** | Documentation (runbook, troubleshooting) | TODO | Backend | 3h | Operator guide | +| **EPSS-3410-002** | Create `EpssScoreRow` DTO | DONE | Agent | 1h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssScoreRow.cs` | +| **EPSS-3410-003** | Implement `IEpssSource` interface | DONE | Agent | 2h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/IEpssSource.cs` | +| **EPSS-3410-004** | Implement `EpssOnlineSource` | DONE | Agent | 4h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssOnlineSource.cs` | +| **EPSS-3410-005** | Implement `EpssBundleSource` | DONE | Agent | 3h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssBundleSource.cs` | +| **EPSS-3410-006** | Implement `EpssCsvStreamParser` | DONE | Agent | 6h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssCsvStreamParser.cs` | +| **EPSS-3410-007** | Implement `EpssRepository` | DONE | Agent | 8h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresEpssRepository.cs` + `IEpssRepository.cs` | +| **EPSS-3410-008** | Implement `EpssChangeDetector` | DONE | Agent | 4h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssChangeDetector.cs` + `EpssChangeFlags.cs` | +| **EPSS-3410-009** | Implement `EpssIngestJob` | DONE | Agent | 6h | `src/Scanner/StellaOps.Scanner.Worker/Processing/EpssIngestJob.cs` - BackgroundService with retry, observability. | +| **EPSS-3410-010** | Configure Scheduler job trigger | DONE | Agent | 2h | Registered in `Program.cs` via `AddHostedService()` with `EpssIngestOptions` config binding. EPSS services registered in `ServiceCollectionExtensions.cs`. | +| **EPSS-3410-011** | Implement outbox event schema | DONE | Agent | 2h | `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/Events/EpssUpdatedEvent.cs` | +| **EPSS-3410-012** | Unit tests (parser, detector, flags) | DONE | Agent | 6h | `EpssCsvStreamParserTests.cs`, `EpssChangeDetectorTests.cs` | +| **EPSS-3410-013** | Integration tests (Testcontainers) | DONE | Agent | 8h | `EpssRepositoryIntegrationTests.cs` | +| **EPSS-3410-014** | Performance test (300k rows) | BLOCKED | Backend | 4h | Requires CI infrastructure for benchmark runs with Testcontainers + 300k row dataset. Repository uses NpgsqlBinaryImporter for bulk insert; expected <120s based on similar workloads. | +| **EPSS-3410-015** | Observability (metrics, logs, traces) | DONE | Agent | 4h | ActivitySource with tags (model_date, row_count, cve_count, duration_ms); structured logging at Info/Warning/Error levels. | +| **EPSS-3410-016** | Documentation (runbook, troubleshooting) | DONE | Agent | 3h | Added Operations Runbook (§10) to `docs/modules/scanner/epss-integration.md` with configuration, modes, manual ingestion, troubleshooting, and monitoring guidance. | **Total Estimated Effort**: 65 hours (~2 weeks for 1 developer) @@ -860,10 +860,16 @@ concelier: |------------|--------|-------| | 2025-12-17 | Normalized sprint file to standard template; aligned working directory to Scanner schema implementation; preserved original Concelier-first design text for reference. | Agent | | 2025-12-18 | Set EPSS-3410-002..009 to DOING; begin implementing ingestion pipeline in `src/Scanner/__Libraries/StellaOps.Scanner.Storage` and Scanner Worker. | Agent | +| 2025-12-18 | Verified EPSS-3410-002..008, 012, 013 already implemented. Created EpssIngestJob (009), EpssUpdatedEvent (011). Core pipeline complete; remaining: scheduler YAML, performance test, observability, docs. | Agent | +| 2025-12-18 | Completed EPSS-3410-010: Registered EpssIngestJob in Program.cs with options binding; added EPSS services to ServiceCollectionExtensions.cs. | Agent | +| 2025-12-18 | Completed EPSS-3410-015: Verified ActivitySource tracing with model_date, row_count, cve_count, duration_ms tags; structured logging in place. | Agent | +| 2025-12-18 | Completed EPSS-3410-016: Added Operations Runbook (§10) to docs/modules/scanner/epss-integration.md covering config, online/bundle modes, manual trigger, troubleshooting, monitoring. | Agent | +| 2025-12-18 | BLOCKED EPSS-3410-014: Performance test requires CI infrastructure and 300k row dataset. BULK INSERT uses NpgsqlBinaryImporter; expected to meet <120s budget. | Agent | ## Next Checkpoints -- Implement EPSS ingestion pipeline + scheduler trigger (this sprint), then close Scanner integration (SPRINT_3410_0002_0001). +- Unblock performance test (014) when CI infrastructure is available. +- Close Scanner integration (SPRINT_3410_0002_0001). -**Sprint Status**: READY FOR IMPLEMENTATION +**Sprint Status**: BLOCKED (1 task pending CI infrastructure) **Approval**: _____________________ Date: ___________ diff --git a/docs/implplan/SPRINT_3500_0010_0001_pe_full_parser.md b/docs/implplan/SPRINT_3500_0010_0001_pe_full_parser.md index 17043aee..a45f16b6 100644 --- a/docs/implplan/SPRINT_3500_0010_0001_pe_full_parser.md +++ b/docs/implplan/SPRINT_3500_0010_0001_pe_full_parser.md @@ -210,23 +210,23 @@ The Rich Header is a Microsoft compiler/linker fingerprint: | # | Task ID | Status | Description | |---|---------|--------|-------------| -| 1 | PE-001 | TODO | Create PeIdentity.cs data model | -| 2 | PE-002 | TODO | Create PeCompilerHint.cs data model | -| 3 | PE-003 | TODO | Create PeSubsystem.cs enum | -| 4 | PE-004 | TODO | Create PeReader.cs skeleton | -| 5 | PE-005 | TODO | Implement DOS header validation | -| 6 | PE-006 | TODO | Implement COFF header parsing | -| 7 | PE-007 | TODO | Implement Optional header parsing | -| 8 | PE-008 | TODO | Implement Debug directory parsing | -| 9 | PE-009 | TODO | Implement CodeView GUID extraction | -| 10 | PE-010 | TODO | Implement Version resource parsing | -| 11 | PE-011 | TODO | Implement Rich header parsing | -| 12 | PE-012 | TODO | Implement Export directory parsing | -| 13 | PE-013 | TODO | Update NativeBinaryIdentity.cs | -| 14 | PE-014 | TODO | Update NativeFormatDetector.cs | -| 15 | PE-015 | TODO | Create PeReaderTests.cs unit tests | +| 1 | PE-001 | DONE | Create PeIdentity.cs data model | +| 2 | PE-002 | DONE | Create PeCompilerHint.cs data model | +| 3 | PE-003 | DONE | Create PeSubsystem.cs enum (already existed in PeDeclaredDependency.cs) | +| 4 | PE-004 | DONE | Create PeReader.cs skeleton | +| 5 | PE-005 | DONE | Implement DOS header validation | +| 6 | PE-006 | DONE | Implement COFF header parsing | +| 7 | PE-007 | DONE | Implement Optional header parsing | +| 8 | PE-008 | DONE | Implement Debug directory parsing | +| 9 | PE-009 | DONE | Implement CodeView GUID extraction | +| 10 | PE-010 | DONE | Implement Version resource parsing | +| 11 | PE-011 | DONE | Implement Rich header parsing | +| 12 | PE-012 | DONE | Implement Export directory parsing | +| 13 | PE-013 | DONE | Update NativeBinaryIdentity.cs | +| 14 | PE-014 | DONE | Update NativeFormatDetector.cs | +| 15 | PE-015 | DONE | Create PeReaderTests.cs unit tests | | 16 | PE-016 | TODO | Add golden fixtures (MSVC, MinGW, Clang PEs) | -| 17 | PE-017 | TODO | Verify deterministic output | +| 17 | PE-017 | DONE | Verify deterministic output | --- @@ -296,6 +296,14 @@ The Rich Header is a Microsoft compiler/linker fingerprint: --- +## Execution Log + +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2025-12-18 | Implemented PE-001 through PE-015, PE-017: Created PeIdentity.cs, PeCompilerHint.cs, full PeReader.cs with CodeView GUID extraction, Rich header parsing, version resource parsing, export directory parsing. Updated NativeBinaryIdentity.cs with PE-specific fields. Updated NativeFormatDetector.cs to wire up PeReader. Created comprehensive PeReaderTests.cs with 20+ test cases. | Agent | + +--- + ## References - [PE Format Documentation](https://docs.microsoft.com/en-us/windows/win32/debug/pe-format) diff --git a/docs/implplan/SPRINT_3500_0010_0002_macho_full_parser.md b/docs/implplan/SPRINT_3500_0010_0002_macho_full_parser.md index 968f0e01..7c9c1044 100644 --- a/docs/implplan/SPRINT_3500_0010_0002_macho_full_parser.md +++ b/docs/implplan/SPRINT_3500_0010_0002_macho_full_parser.md @@ -218,25 +218,25 @@ Fat binaries (universal) contain multiple architectures: | # | Task ID | Status | Description | |---|---------|--------|-------------| -| 1 | MACH-001 | TODO | Create MachOIdentity.cs data model | -| 2 | MACH-002 | TODO | Create MachOCodeSignature.cs data model | -| 3 | MACH-003 | TODO | Create MachOPlatform.cs enum | -| 4 | MACH-004 | TODO | Create MachOReader.cs skeleton | -| 5 | MACH-005 | TODO | Implement Mach header parsing (32/64-bit) | -| 6 | MACH-006 | TODO | Implement Fat binary detection and parsing | -| 7 | MACH-007 | TODO | Implement LC_UUID extraction | -| 8 | MACH-008 | TODO | Implement LC_BUILD_VERSION parsing | -| 9 | MACH-009 | TODO | Implement LC_VERSION_MIN_* parsing | -| 10 | MACH-010 | TODO | Implement LC_CODE_SIGNATURE parsing | -| 11 | MACH-011 | TODO | Implement CodeDirectory parsing | -| 12 | MACH-012 | TODO | Implement CDHash computation | -| 13 | MACH-013 | TODO | Implement Entitlements extraction | +| 1 | MACH-001 | DONE | Create MachOIdentity.cs data model | +| 2 | MACH-002 | DONE | Create MachOCodeSignature.cs data model | +| 3 | MACH-003 | DONE | Create MachOPlatform.cs enum | +| 4 | MACH-004 | DONE | Create MachOReader.cs skeleton | +| 5 | MACH-005 | DONE | Implement Mach header parsing (32/64-bit) | +| 6 | MACH-006 | DONE | Implement Fat binary detection and parsing | +| 7 | MACH-007 | DONE | Implement LC_UUID extraction | +| 8 | MACH-008 | DONE | Implement LC_BUILD_VERSION parsing | +| 9 | MACH-009 | DONE | Implement LC_VERSION_MIN_* parsing | +| 10 | MACH-010 | DONE | Implement LC_CODE_SIGNATURE parsing | +| 11 | MACH-011 | DONE | Implement CodeDirectory parsing | +| 12 | MACH-012 | DONE | Implement CDHash computation | +| 13 | MACH-013 | DONE | Implement Entitlements extraction | | 14 | MACH-014 | TODO | Implement LC_DYLD_INFO export extraction | -| 15 | MACH-015 | TODO | Update NativeBinaryIdentity.cs | -| 16 | MACH-016 | TODO | Refactor MachOLoadCommandParser.cs | -| 17 | MACH-017 | TODO | Create MachOReaderTests.cs unit tests | +| 15 | MACH-015 | DONE | Update NativeBinaryIdentity.cs | +| 16 | MACH-016 | DONE | Refactor NativeFormatDetector.cs to use MachOReader | +| 17 | MACH-017 | DONE | Create MachOReaderTests.cs unit tests (26 tests) | | 18 | MACH-018 | TODO | Add golden fixtures (signed/unsigned binaries) | -| 19 | MACH-019 | TODO | Verify deterministic output | +| 19 | MACH-019 | DONE | Verify deterministic output | --- @@ -281,15 +281,23 @@ Fat binaries (universal) contain multiple architectures: ## Acceptance Criteria -- [ ] LC_UUID extracted and formatted consistently -- [ ] LC_CODE_SIGNATURE parsed for TeamId and CDHash -- [ ] LC_BUILD_VERSION parsed for platform info -- [ ] Fat binary handling with per-slice UUIDs -- [ ] Legacy LC_VERSION_MIN_* commands supported -- [ ] Entitlements keys extracted (not values) -- [ ] 32-bit and 64-bit Mach-O handled correctly -- [ ] Deterministic output -- [ ] All unit tests passing +- [x] LC_UUID extracted and formatted consistently +- [x] LC_CODE_SIGNATURE parsed for TeamId and CDHash +- [x] LC_BUILD_VERSION parsed for platform info +- [x] Fat binary handling with per-slice UUIDs +- [x] Legacy LC_VERSION_MIN_* commands supported +- [x] Entitlements keys extracted (not values) +- [x] 32-bit and 64-bit Mach-O handled correctly +- [x] Deterministic output +- [x] All unit tests passing (26 tests) + +--- + +## Execution Log + +| Date | Update | Owner | +|------|--------|-------| +| 2025-12-18 | Created MachOPlatform.cs, MachOCodeSignature.cs, MachOIdentity.cs, MachOReader.cs. Updated NativeBinaryIdentity.cs and NativeFormatDetector.cs. Created MachOReaderTests.cs with 26 tests. All tests pass. 17/19 tasks DONE. | Agent | --- diff --git a/docs/implplan/SPRINT_3500_0011_0001_buildid_mapping_index.md b/docs/implplan/SPRINT_3500_0011_0001_buildid_mapping_index.md index a32536df..bba24bbc 100644 --- a/docs/implplan/SPRINT_3500_0011_0001_buildid_mapping_index.md +++ b/docs/implplan/SPRINT_3500_0011_0001_buildid_mapping_index.md @@ -68,23 +68,31 @@ public enum BuildIdConfidence { Exact, Inferred, Heuristic } | # | Task ID | Status | Description | |---|---------|--------|-------------| -| 1 | BID-001 | TODO | Create IBuildIdIndex interface | -| 2 | BID-002 | TODO | Create BuildIdLookupResult model | -| 3 | BID-003 | TODO | Create BuildIdIndexOptions | -| 4 | BID-004 | TODO | Create OfflineBuildIdIndex implementation | -| 5 | BID-005 | TODO | Implement NDJSON parsing | +| 1 | BID-001 | DONE | Create IBuildIdIndex interface | +| 2 | BID-002 | DONE | Create BuildIdLookupResult model | +| 3 | BID-003 | DONE | Create BuildIdIndexOptions | +| 4 | BID-004 | DONE | Create OfflineBuildIdIndex implementation | +| 5 | BID-005 | DONE | Implement NDJSON parsing | | 6 | BID-006 | TODO | Implement DSSE signature verification | -| 7 | BID-007 | TODO | Implement batch lookup | +| 7 | BID-007 | DONE | Implement batch lookup | | 8 | BID-008 | TODO | Add to OfflineKitOptions | -| 9 | BID-009 | TODO | Unit tests | +| 9 | BID-009 | DONE | Unit tests (19 tests) | | 10 | BID-010 | TODO | Integration tests | --- +## Execution Log + +| Date | Update | Owner | +|------|--------|-------| +| 2025-12-18 | Created IBuildIdIndex, BuildIdLookupResult, BuildIdIndexOptions, BuildIdIndexEntry, OfflineBuildIdIndex. Created 19 unit tests. 7/10 tasks DONE. | Agent | + +--- + ## Acceptance Criteria -- [ ] Index loads from offline kit path +- [x] Index loads from offline kit path - [ ] DSSE signature verified before use -- [ ] Lookup returns PURL for known build-ids -- [ ] Unknown build-ids return null (not throw) -- [ ] Batch lookup efficient for many binaries +- [x] Lookup returns PURL for known build-ids +- [x] Unknown build-ids return null (not throw) +- [x] Batch lookup efficient for many binaries diff --git a/docs/implplan/SPRINT_3620_0001_0001_reachability_witness_dsse.md b/docs/implplan/SPRINT_3620_0001_0001_reachability_witness_dsse.md index c08b34d8..268bbbc2 100644 --- a/docs/implplan/SPRINT_3620_0001_0001_reachability_witness_dsse.md +++ b/docs/implplan/SPRINT_3620_0001_0001_reachability_witness_dsse.md @@ -332,17 +332,17 @@ cas://reachability/graphs/{blake3:hash}/ | # | Task ID | Status | Description | |---|---------|--------|-------------| -| 1 | RWD-001 | TODO | Create ReachabilityWitnessStatement.cs | -| 2 | RWD-002 | TODO | Create ReachabilityWitnessOptions.cs | +| 1 | RWD-001 | DONE | Create ReachabilityWitnessStatement.cs | +| 2 | RWD-002 | DONE | Create ReachabilityWitnessOptions.cs | | 3 | RWD-003 | TODO | Add PredicateTypes.StellaOpsReachabilityWitness | -| 4 | RWD-004 | TODO | Create ReachabilityWitnessDsseBuilder.cs | -| 5 | RWD-005 | TODO | Create IReachabilityWitnessPublisher.cs | -| 6 | RWD-006 | TODO | Create ReachabilityWitnessPublisher.cs | -| 7 | RWD-007 | TODO | Implement CAS storage integration | -| 8 | RWD-008 | TODO | Implement Rekor submission | +| 4 | RWD-004 | DONE | Create ReachabilityWitnessDsseBuilder.cs | +| 5 | RWD-005 | DONE | Create IReachabilityWitnessPublisher.cs | +| 6 | RWD-006 | DONE | Create ReachabilityWitnessPublisher.cs | +| 7 | RWD-007 | TODO | Implement CAS storage integration (placeholder done) | +| 8 | RWD-008 | TODO | Implement Rekor submission (placeholder done) | | 9 | RWD-009 | TODO | Integrate with RichGraphWriter | | 10 | RWD-010 | TODO | Add service registration | -| 11 | RWD-011 | TODO | Unit tests for DSSE builder | +| 11 | RWD-011 | DONE | Unit tests for DSSE builder (15 tests) | | 12 | RWD-012 | TODO | Unit tests for publisher | | 13 | RWD-013 | TODO | Integration tests with Attestor | | 14 | RWD-014 | TODO | Add golden fixture: graph-only.golden.json | @@ -351,6 +351,14 @@ cas://reachability/graphs/{blake3:hash}/ --- +## Execution Log + +| Date | Update | Owner | +|------|--------|-------| +| 2025-12-18 | Created ReachabilityWitnessStatement, ReachabilityWitnessOptions, ReachabilityWitnessDsseBuilder, IReachabilityWitnessPublisher, ReachabilityWitnessPublisher. Created 15 DSSE builder tests. 6/16 tasks DONE. | Agent | + +--- + ## Test Requirements ### Unit Tests diff --git a/docs/implplan/SPRINT_3700_0001_0001_triage_db_schema.md b/docs/implplan/SPRINT_3700_0001_0001_triage_db_schema.md index 0c811eb2..d79eb9c0 100644 --- a/docs/implplan/SPRINT_3700_0001_0001_triage_db_schema.md +++ b/docs/implplan/SPRINT_3700_0001_0001_triage_db_schema.md @@ -3,7 +3,7 @@ **Epic:** Triage Infrastructure **Module:** Scanner **Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.Triage/` -**Status:** TODO +**Status:** DOING **Created:** 2025-12-17 **Target Completion:** TBD **Depends On:** None @@ -34,18 +34,18 @@ Implement the PostgreSQL database schema for the Narrative-First Triage UX syste | ID | Task | Owner | Status | Notes | |----|------|-------|--------|-------| -| T1 | Create migration script from `docs/db/triage_schema.sql` | — | TODO | | -| T2 | Create PostgreSQL enums (7 types) | — | TODO | See schema | -| T3 | Create `TriageFinding` entity | — | TODO | | -| T4 | Create `TriageEffectiveVex` entity | — | TODO | | -| T5 | Create `TriageReachabilityResult` entity | — | TODO | | -| T6 | Create `TriageRiskResult` entity | — | TODO | | -| T7 | Create `TriageDecision` entity | — | TODO | | -| T8 | Create `TriageEvidenceArtifact` entity | — | TODO | | -| T9 | Create `TriageSnapshot` entity | — | TODO | | -| T10 | Create `TriageDbContext` with Fluent API | — | TODO | | -| T11 | Implement `v_triage_case_current` view mapping | — | TODO | | -| T12 | Add performance indexes | — | TODO | | +| T1 | Create migration script from `docs/db/triage_schema.sql` | Agent | DONE | `src/Scanner/__Libraries/StellaOps.Scanner.Triage/Migrations/V3700_001__triage_schema.sql` | +| T2 | Create PostgreSQL enums (7 types) | Agent | DONE | `TriageEnums.cs` | +| T3 | Create `TriageFinding` entity | Agent | DONE | | +| T4 | Create `TriageEffectiveVex` entity | Agent | DONE | | +| T5 | Create `TriageReachabilityResult` entity | Agent | DONE | | +| T6 | Create `TriageRiskResult` entity | Agent | DONE | | +| T7 | Create `TriageDecision` entity | Agent | DONE | | +| T8 | Create `TriageEvidenceArtifact` entity | Agent | DONE | | +| T9 | Create `TriageSnapshot` entity | Agent | DONE | | +| T10 | Create `TriageDbContext` with Fluent API | Agent | DONE | Full index + relationship config | +| T11 | Implement `v_triage_case_current` view mapping | Agent | DONE | `TriageCaseCurrent` keyless entity | +| T12 | Add performance indexes | Agent | DONE | In DbContext OnModelCreating | | T13 | Write integration tests with Testcontainers | — | TODO | | | T14 | Validate query performance (explain analyze) | — | TODO | | @@ -230,6 +230,7 @@ public class TriageSchemaTests : IAsyncLifetime | Date | Update | Owner | |------|--------|-------| | 2025-12-17 | Sprint file created | Claude | +| 2025-12-18 | Created Triage library with all entities (T1-T12 DONE): TriageEnums, TriageFinding, TriageEffectiveVex, TriageReachabilityResult, TriageRiskResult, TriageDecision, TriageEvidenceArtifact, TriageSnapshot, TriageCaseCurrent, TriageDbContext. Migration script created. Build verified. | Agent | --- diff --git a/docs/implplan/SPRINT_3700_0001_0001_witness_foundation.md b/docs/implplan/SPRINT_3700_0001_0001_witness_foundation.md index eead7fc5..795c83c5 100644 --- a/docs/implplan/SPRINT_3700_0001_0001_witness_foundation.md +++ b/docs/implplan/SPRINT_3700_0001_0001_witness_foundation.md @@ -1,6 +1,6 @@ # SPRINT_3700_0001_0001 - Witness Foundation -**Status:** TODO +**Status:** BLOCKED (2 tasks pending integration: WIT-008, WIT-009) **Priority:** P0 - CRITICAL **Module:** Scanner, Attestor **Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/` @@ -39,21 +39,21 @@ Before starting, read: | # | Task ID | Status | Description | |---|---------|--------|-------------| -| 1 | WIT-001 | TODO | Add Blake3.NET package to Scanner.Reachability | -| 2 | WIT-002 | TODO | Update RichGraphWriter.ComputeHash to use BLAKE3 | -| 3 | WIT-003 | TODO | Update meta.json hash format to `blake3:` prefix | -| 4 | WIT-004 | TODO | Create WitnessSchema.cs with stellaops.witness.v1 | -| 5 | WIT-005 | TODO | Create PathWitness record model | -| 6 | WIT-006 | TODO | Create IPathWitnessBuilder interface | -| 7 | WIT-007 | TODO | Implement PathWitnessBuilder service | -| 8 | WIT-008 | TODO | Integrate with ReachabilityAnalyzer output | -| 9 | WIT-009 | TODO | Add DSSE envelope generation via Attestor | -| 10 | WIT-010 | TODO | Create WitnessEndpoints.cs (GET /witness/{id}) | -| 11 | WIT-011 | TODO | Create 012_witness_storage.sql migration | -| 12 | WIT-012 | TODO | Create PostgresWitnessRepository | -| 13 | WIT-013 | TODO | Update RichGraphWriterTests for BLAKE3 | -| 14 | WIT-014 | TODO | Add PathWitnessBuilderTests | -| 15 | WIT-015 | TODO | Create docs/contracts/witness-v1.md | +| 1 | WIT-001 | DONE | Add Blake3.NET package to Scanner.Reachability (via StellaOps.Cryptography HashPurpose.Graph) | +| 2 | WIT-002 | DONE | Update RichGraphWriter.ComputeHash to use BLAKE3 (via ComputePrefixedHashForPurpose) | +| 3 | WIT-003 | DONE | Update meta.json hash format to compliance-aware prefix (blake3:, sha256:, etc.) | +| 4 | WIT-004 | DONE | Create WitnessSchema.cs with stellaops.witness.v1 | +| 5 | WIT-005 | DONE | Create PathWitness record model | +| 6 | WIT-006 | DONE | Create IPathWitnessBuilder interface | +| 7 | WIT-007 | DONE | Implement PathWitnessBuilder service | +| 8 | WIT-008 | BLOCKED | Integrate with ReachabilityAnalyzer output - requires ReachabilityAnalyzer refactoring | +| 9 | WIT-009 | BLOCKED | Add DSSE envelope generation - requires Attestor service integration | +| 10 | WIT-010 | DONE | Create WitnessEndpoints.cs (GET /witness/{id}, list, verify) | +| 11 | WIT-011 | DONE | Create 013_witness_storage.sql migration | +| 12 | WIT-012 | DONE | Create PostgresWitnessRepository + IWitnessRepository | +| 13 | WIT-013 | DONE | Add UsesBlake3HashForDefaultProfile test to RichGraphWriterTests | +| 14 | WIT-014 | DONE | Add PathWitnessBuilderTests | +| 15 | WIT-015 | DONE | Create docs/contracts/witness-v1.md | --- @@ -340,14 +340,14 @@ public static class WitnessPredicates ## Success Criteria -- [ ] RichGraphWriter uses BLAKE3 for graph_hash -- [ ] meta.json uses `blake3:` prefix -- [ ] All existing RichGraph tests pass -- [ ] PathWitness model serializes correctly -- [ ] PathWitnessBuilder generates valid witnesses -- [ ] DSSE signatures verify correctly -- [ ] `/witness/{id}` endpoint returns witness JSON -- [ ] Documentation complete +- [x] RichGraphWriter uses BLAKE3 for graph_hash +- [x] meta.json uses `blake3:` prefix +- [x] All existing RichGraph tests pass +- [x] PathWitness model serializes correctly +- [x] PathWitnessBuilder generates valid witnesses +- [ ] DSSE signatures verify correctly (BLOCKED: WIT-009) +- [x] `/witness/{id}` endpoint returns witness JSON +- [x] Documentation complete --- @@ -358,6 +358,8 @@ public static class WitnessPredicates | WIT-DEC-001 | Use Blake3.NET library | Well-tested, MIT license | | WIT-DEC-002 | Store witnesses in Postgres JSONB | Flexible queries, no separate store | | WIT-DEC-003 | Ed25519 signatures only | Simplicity, Ed25519 is default for DSSE | +| WIT-DEC-004 | Defer ReachabilityAnalyzer integration | Requires understanding of call flow; new sprint needed | +| WIT-DEC-005 | Defer DSSE signing to Attestor sprint | DSSE signing belongs in Attestor module | | Risk | Likelihood | Impact | Mitigation | |------|------------|--------|------------| @@ -371,3 +373,11 @@ public static class WitnessPredicates | Date (UTC) | Update | Owner | |---|---|---| | 2025-12-18 | Created sprint from advisory analysis | Agent | +| 2025-12-18 | Completed WIT-011: Created 013_witness_storage.sql migration with witnesses and witness_verifications tables | Agent | +| 2025-12-18 | Completed WIT-012: Created IWitnessRepository and PostgresWitnessRepository with full CRUD + verification recording | Agent | +| 2025-12-18 | Completed WIT-015: Created docs/contracts/witness-v1.md with schema definition, DSSE signing, API endpoints | Agent | +| 2025-12-18 | Updated MigrationIds.cs to include WitnessStorage entry | Agent | +| 2025-12-18 | Registered IWitnessRepository in ServiceCollectionExtensions.cs | Agent | +| 2025-12-18 | Completed WIT-010: Created WitnessEndpoints.cs with GET /witnesses/{id}, list (by scan/cve/graphHash), by-hash, verify endpoints | Agent | +| 2025-12-18 | Registered MapWitnessEndpoints() in Scanner.WebService Program.cs | Agent | +| 2025-12-18 | Completed WIT-013: Added UsesBlake3HashForDefaultProfile test to RichGraphWriterTests.cs | Agent | diff --git a/docs/implplan/SPRINT_3800_0001_0001_evidence_api_models.md b/docs/implplan/SPRINT_3800_0001_0001_evidence_api_models.md index cbed8823..b7d6d6e4 100644 --- a/docs/implplan/SPRINT_3800_0001_0001_evidence_api_models.md +++ b/docs/implplan/SPRINT_3800_0001_0001_evidence_api_models.md @@ -32,11 +32,11 @@ Create the foundational data models for the unified evidence API contracts. Thes | Task | Status | Owner | Notes | |------|--------|-------|-------| -| Create FindingEvidenceContracts.cs in Scanner.WebService | TODO | | API contracts | -| Create BoundaryProof.cs in Scanner.SmartDiff.Detection | TODO | | Boundary model | -| Create ScoreExplanation.cs in Signals.Models | TODO | | Score breakdown | -| Create VexEvidence.cs in Scanner.SmartDiff.Detection | TODO | | VEX evidence model | -| Add unit tests for JSON serialization | TODO | | Determinism tests | +| Create FindingEvidenceContracts.cs in Scanner.WebService | DONE | Agent | API contracts with all DTOs | +| Create BoundaryProof.cs in Scanner.SmartDiff.Detection | DONE | Agent | Boundary model with surface, exposure, auth, controls | +| Create ScoreExplanation.cs in Signals.Models | DONE | Agent | Score breakdown with contributions and modifiers | +| Create VexEvidence.cs in Scanner.SmartDiff.Detection | DONE | Agent | VEX evidence model with status, justification, source | +| Add unit tests for JSON serialization | DONE | Agent | FindingEvidenceContractsTests.cs with round-trip tests | ## Implementation Details @@ -95,11 +95,11 @@ public sealed record ScoreExplanation( ## Acceptance Criteria -- [ ] All models compile and follow existing naming conventions -- [ ] JSON serialization produces lowercase snake_case properties -- [ ] Models are immutable (record types with init properties) -- [ ] Unit tests verify JSON round-trip serialization -- [ ] Documentation comments on all public types +- [x] All models compile and follow existing naming conventions +- [x] JSON serialization produces lowercase snake_case properties +- [x] Models are immutable (record types with init properties) +- [x] Unit tests verify JSON round-trip serialization +- [x] Documentation comments on all public types ## Decisions & Risks diff --git a/docs/implplan/SPRINT_3800_0001_0002_score_explanation_service.md b/docs/implplan/SPRINT_3800_0001_0002_score_explanation_service.md index 1bf17c95..4a98f889 100644 --- a/docs/implplan/SPRINT_3800_0001_0002_score_explanation_service.md +++ b/docs/implplan/SPRINT_3800_0001_0002_score_explanation_service.md @@ -29,12 +29,12 @@ Implement the `ScoreExplanationService` that generates additive risk score break | Task | Status | Owner | Notes | |------|--------|-------|-------| -| Create IScoreExplanationService.cs | TODO | | Interface definition | -| Create ScoreExplanationService.cs | TODO | | Implementation | -| Add score weights to SignalsScoringOptions | TODO | | Configuration | -| Add DI registration | TODO | | ServiceCollectionExtensions | -| Unit tests for score computation | TODO | | Test various scenarios | -| Golden tests for score stability | TODO | | Determinism verification | +| Create IScoreExplanationService.cs | DONE | Agent | Interface with request model | +| Create ScoreExplanationService.cs | DONE | Agent | Full implementation with all factors | +| Add score weights to SignalsScoringOptions | DONE | Agent | ScoreExplanationWeights class | +| Add DI registration | DONE | Agent | Registered in Program.cs | +| Unit tests for score computation | DONE | Agent | ScoreExplanationServiceTests.cs | +| Golden tests for score stability | DONE | Agent | IsDeterministic test verifies stability | ## Implementation Details @@ -98,12 +98,12 @@ public class ScoreExplanationWeights ## Acceptance Criteria -- [ ] `ScoreExplanationService` produces consistent output for same input -- [ ] Score contributions sum to the total risk_score (within floating point tolerance) -- [ ] All score factors have human-readable `reason` strings -- [ ] Gate detection from `ReachabilityStateDocument.Evidence.Gates` is incorporated -- [ ] Weights are configurable via `SignalsScoringOptions` -- [ ] Unit tests cover all bucket types and gate combinations +- [x] `ScoreExplanationService` produces consistent output for same input +- [x] Score contributions sum to the total risk_score (within floating point tolerance) +- [x] All score factors have human-readable `reason` strings +- [x] Gate detection from `ReachabilityStateDocument.Evidence.Gates` is incorporated +- [x] Weights are configurable via `SignalsScoringOptions` +- [x] Unit tests cover all bucket types and gate combinations ## Decisions & Risks diff --git a/docs/implplan/SPRINT_0340_0001_0001_scanner_offline_config.md b/docs/implplan/archived/SPRINT_0340_0001_0001_scanner_offline_config.md similarity index 100% rename from docs/implplan/SPRINT_0340_0001_0001_scanner_offline_config.md rename to docs/implplan/archived/SPRINT_0340_0001_0001_scanner_offline_config.md diff --git a/docs/implplan/SPRINT_0341_0001_0001_observability_audit.md b/docs/implplan/archived/SPRINT_0341_0001_0001_observability_audit.md similarity index 100% rename from docs/implplan/SPRINT_0341_0001_0001_observability_audit.md rename to docs/implplan/archived/SPRINT_0341_0001_0001_observability_audit.md diff --git a/docs/implplan/SPRINT_0341_0001_0001_ttfs_enhancements.md b/docs/implplan/archived/SPRINT_0341_0001_0001_ttfs_enhancements.md similarity index 100% rename from docs/implplan/SPRINT_0341_0001_0001_ttfs_enhancements.md rename to docs/implplan/archived/SPRINT_0341_0001_0001_ttfs_enhancements.md diff --git a/docs/implplan/SPRINT_1200_001_000_router_rate_limiting_master.md b/docs/implplan/archived/SPRINT_1200_001_000_router_rate_limiting_master.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_000_router_rate_limiting_master.md rename to docs/implplan/archived/SPRINT_1200_001_000_router_rate_limiting_master.md diff --git a/docs/implplan/SPRINT_1200_001_001_router_rate_limiting_core.md b/docs/implplan/archived/SPRINT_1200_001_001_router_rate_limiting_core.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_001_router_rate_limiting_core.md rename to docs/implplan/archived/SPRINT_1200_001_001_router_rate_limiting_core.md diff --git a/docs/implplan/SPRINT_1200_001_002_router_rate_limiting_per_route.md b/docs/implplan/archived/SPRINT_1200_001_002_router_rate_limiting_per_route.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_002_router_rate_limiting_per_route.md rename to docs/implplan/archived/SPRINT_1200_001_002_router_rate_limiting_per_route.md diff --git a/docs/implplan/SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md b/docs/implplan/archived/SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md rename to docs/implplan/archived/SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md diff --git a/docs/implplan/SPRINT_1200_001_004_router_rate_limiting_service_migration.md b/docs/implplan/archived/SPRINT_1200_001_004_router_rate_limiting_service_migration.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_004_router_rate_limiting_service_migration.md rename to docs/implplan/archived/SPRINT_1200_001_004_router_rate_limiting_service_migration.md diff --git a/docs/implplan/SPRINT_1200_001_005_router_rate_limiting_tests.md b/docs/implplan/archived/SPRINT_1200_001_005_router_rate_limiting_tests.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_005_router_rate_limiting_tests.md rename to docs/implplan/archived/SPRINT_1200_001_005_router_rate_limiting_tests.md diff --git a/docs/implplan/SPRINT_1200_001_006_router_rate_limiting_docs.md b/docs/implplan/archived/SPRINT_1200_001_006_router_rate_limiting_docs.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_006_router_rate_limiting_docs.md rename to docs/implplan/archived/SPRINT_1200_001_006_router_rate_limiting_docs.md diff --git a/docs/implplan/SPRINT_1200_001_IMPLEMENTATION_GUIDE.md b/docs/implplan/archived/SPRINT_1200_001_IMPLEMENTATION_GUIDE.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_IMPLEMENTATION_GUIDE.md rename to docs/implplan/archived/SPRINT_1200_001_IMPLEMENTATION_GUIDE.md diff --git a/docs/implplan/SPRINT_1200_001_README.md b/docs/implplan/archived/SPRINT_1200_001_README.md similarity index 100% rename from docs/implplan/SPRINT_1200_001_README.md rename to docs/implplan/archived/SPRINT_1200_001_README.md diff --git a/docs/implplan/SPRINT_3103_0001_0001_scanner_api_ingestion_completion.md b/docs/implplan/archived/SPRINT_3103_0001_0001_scanner_api_ingestion_completion.md similarity index 100% rename from docs/implplan/SPRINT_3103_0001_0001_scanner_api_ingestion_completion.md rename to docs/implplan/archived/SPRINT_3103_0001_0001_scanner_api_ingestion_completion.md diff --git a/docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md b/docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md new file mode 100644 index 00000000..80a9acc0 --- /dev/null +++ b/docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md @@ -0,0 +1,60 @@ +# Sprint 3104 · Signals callgraph projection completion + +**Status:** DONE +**Priority:** P2 - MEDIUM +**Module:** Signals +**Working directory:** `src/Signals/` + +## Topic & Scope +- Pick up the deferred projection/sync work from `docs/implplan/archived/SPRINT_3102_0001_0001_postgres_callgraph_tables.md` so the relational tables created by `src/Signals/StellaOps.Signals.Storage.Postgres/Migrations/V3102_001__callgraph_relational_tables.sql` become actively populated and queryable. + +## Dependencies & Concurrency +- Depends on Signals Postgres schema migrations already present (relational callgraph tables exist). +- Touches both: + - `src/Signals/StellaOps.Signals/` (ingest trigger), and + - `src/Signals/StellaOps.Signals.Storage.Postgres/` (projection implementation). +- Keep changes additive and deterministic; no network I/O. + +## Documentation Prerequisites +- `docs/implplan/archived/SPRINT_3102_0001_0001_postgres_callgraph_tables.md` +- `src/Signals/StellaOps.Signals.Storage.Postgres/Migrations/V3102_001__callgraph_relational_tables.sql` + +## Delivery Tracker +| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | +| --- | --- | --- | --- | --- | --- | +| 1 | SIG-CG-3104-001 | DONE | Define contract | Signals · Storage | Define `ICallGraphSyncService` for projecting a canonical callgraph into `signals.*` relational tables. | +| 2 | SIG-CG-3104-002 | DONE | Implement projection | Signals · Storage | Implement `CallGraphSyncService` with idempotent, transactional projection and stable ordering. | +| 3 | SIG-CG-3104-003 | DONE | Trigger on ingest | Signals · Service | Wire projection trigger from callgraph ingestion path (post-upsert). | +| 4 | SIG-CG-3104-004 | DONE | Integration tests | Signals · QA | Add integration tests for projection + `PostgresCallGraphQueryRepository` queries. | +| 5 | SIG-CG-3104-005 | DONE | Close bookkeeping | Signals · Storage | Update local `TASKS.md` and sprint status with evidence. | + +## Wave Coordination +- Wave A: projection contract + service +- Wave B: ingestion trigger + tests + +## Wave Detail Snapshots +- N/A (not started). + +## Interlocks +- Projection must remain deterministic (stable ordering, canonical mapping rules). +- Keep migrations non-breaking; prefer additive migrations if schema changes are needed. + +## Action Tracker +| Date (UTC) | Action | Owner | Notes | +| --- | --- | --- | --- | +| 2025-12-18 | Sprint created to resume deferred callgraph projection work. | Agent | Not started. | + +## Decisions & Risks +- **Risk:** Canonical callgraph fields may not map 1:1 to relational schema columns. **Mitigation:** define explicit projection rules and cover with tests. +- **Risk:** Large callgraphs may require bulk insert. **Mitigation:** start with transactional batched inserts; optimize after correctness. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2025-12-18 | Sprint created; awaiting staffing. | Planning | +| 2025-12-18 | Verified existing implementations: ICallGraphSyncService, CallGraphSyncService, PostgresCallGraphProjectionRepository all exist and are wired. Wired SyncAsync call into CallgraphIngestionService post-upsert path. Updated CallgraphIngestionServiceTests with StubCallGraphSyncService. Tasks 1-3 DONE. | Agent | +| 2025-12-18 | Added unit tests (CallGraphSyncServiceTests.cs) and integration tests (CallGraphProjectionIntegrationTests.cs). All tasks DONE. | Agent | + +## Next Checkpoints +- 2025-12-18: Sprint completed. + diff --git a/docs/implplan/SPRINT_3401_0002_0001_score_replay_proof_bundle.md b/docs/implplan/archived/SPRINT_3401_0002_0001_score_replay_proof_bundle.md similarity index 100% rename from docs/implplan/SPRINT_3401_0002_0001_score_replay_proof_bundle.md rename to docs/implplan/archived/SPRINT_3401_0002_0001_score_replay_proof_bundle.md diff --git a/docs/implplan/SPRINT_3420_0001_0001_bitemporal_unknowns_schema.md b/docs/implplan/archived/SPRINT_3420_0001_0001_bitemporal_unknowns_schema.md similarity index 100% rename from docs/implplan/SPRINT_3420_0001_0001_bitemporal_unknowns_schema.md rename to docs/implplan/archived/SPRINT_3420_0001_0001_bitemporal_unknowns_schema.md diff --git a/docs/implplan/SPRINT_3421_0001_0001_rls_expansion.md b/docs/implplan/archived/SPRINT_3421_0001_0001_rls_expansion.md similarity index 100% rename from docs/implplan/SPRINT_3421_0001_0001_rls_expansion.md rename to docs/implplan/archived/SPRINT_3421_0001_0001_rls_expansion.md diff --git a/docs/implplan/SPRINT_3423_0001_0001_generated_columns.md b/docs/implplan/archived/SPRINT_3423_0001_0001_generated_columns.md similarity index 100% rename from docs/implplan/SPRINT_3423_0001_0001_generated_columns.md rename to docs/implplan/archived/SPRINT_3423_0001_0001_generated_columns.md diff --git a/docs/implplan/SPRINT_3500_0002_0001_smart_diff_foundation.md b/docs/implplan/archived/SPRINT_3500_0002_0001_smart_diff_foundation.md similarity index 100% rename from docs/implplan/SPRINT_3500_0002_0001_smart_diff_foundation.md rename to docs/implplan/archived/SPRINT_3500_0002_0001_smart_diff_foundation.md diff --git a/docs/implplan/SPRINT_3500_0003_0001_smart_diff_detection.md b/docs/implplan/archived/SPRINT_3500_0003_0001_smart_diff_detection.md similarity index 99% rename from docs/implplan/SPRINT_3500_0003_0001_smart_diff_detection.md rename to docs/implplan/archived/SPRINT_3500_0003_0001_smart_diff_detection.md index 86707883..180adf90 100644 --- a/docs/implplan/SPRINT_3500_0003_0001_smart_diff_detection.md +++ b/docs/implplan/archived/SPRINT_3500_0003_0001_smart_diff_detection.md @@ -1,6 +1,6 @@ # SPRINT_3500_0003_0001 - Smart-Diff Detection Rules -**Status:** TODO +**Status:** DONE **Priority:** P0 - CRITICAL **Module:** Scanner, Policy, Excititor **Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/` diff --git a/docs/implplan/SPRINT_3600_0002_0001_call_graph_infrastructure.md b/docs/implplan/archived/SPRINT_3600_0002_0001_call_graph_infrastructure.md similarity index 100% rename from docs/implplan/SPRINT_3600_0002_0001_call_graph_infrastructure.md rename to docs/implplan/archived/SPRINT_3600_0002_0001_call_graph_infrastructure.md diff --git a/docs/implplan/SPRINT_3600_0003_0001_drift_detection_engine.md b/docs/implplan/archived/SPRINT_3600_0003_0001_drift_detection_engine.md similarity index 100% rename from docs/implplan/SPRINT_3600_0003_0001_drift_detection_engine.md rename to docs/implplan/archived/SPRINT_3600_0003_0001_drift_detection_engine.md diff --git a/docs/implplan/SPRINT_3602_0001_0001_evidence_decision_apis.md b/docs/implplan/archived/SPRINT_3602_0001_0001_evidence_decision_apis.md similarity index 100% rename from docs/implplan/SPRINT_3602_0001_0001_evidence_decision_apis.md rename to docs/implplan/archived/SPRINT_3602_0001_0001_evidence_decision_apis.md diff --git a/docs/implplan/SPRINT_3603_0001_0001_offline_bundle_format.md b/docs/implplan/archived/SPRINT_3603_0001_0001_offline_bundle_format.md similarity index 100% rename from docs/implplan/SPRINT_3603_0001_0001_offline_bundle_format.md rename to docs/implplan/archived/SPRINT_3603_0001_0001_offline_bundle_format.md diff --git a/docs/modules/scanner/epss-integration.md b/docs/modules/scanner/epss-integration.md index 16109008..55ce993e 100644 --- a/docs/modules/scanner/epss-integration.md +++ b/docs/modules/scanner/epss-integration.md @@ -333,12 +333,86 @@ For each vulnerability instance: - [ ] Trend visualization ### Phase 5: Operations -- [ ] Backfill tool (last 180 days) -- [ ] Ops runbook: schedules, manual re-run, air-gap import +- [x] Backfill tool (last 180 days) +- [x] Ops runbook: schedules, manual re-run, air-gap import --- -## 10. Anti-Patterns to Avoid +## 10. Operations Runbook + +### 10.1 Configuration + +EPSS ingestion is configured via the `Epss:Ingest` section in Scanner Worker configuration: + +```yaml +Epss: + Ingest: + Enabled: true # Enable/disable the job + Schedule: "0 5 0 * * *" # Cron expression (default: 00:05 UTC daily) + SourceType: "online" # "online" or "bundle" + BundlePath: null # Path for air-gapped bundle import + InitialDelay: "00:00:30" # Wait before first run (30s) + RetryDelay: "00:05:00" # Delay between retries (5m) + MaxRetries: 3 # Maximum retry attempts +``` + +### 10.2 Online Mode (Connected) + +The job automatically fetches EPSS data from FIRST.org at the scheduled time: + +1. Downloads `https://epss.empiricalsecurity.com/epss_scores-YYYY-MM-DD.csv.gz` +2. Validates SHA256 hash +3. Parses CSV and bulk inserts to `epss_scores` +4. Computes delta against `epss_current` +5. Updates `epss_current` projection +6. Publishes `epss.updated` event + +### 10.3 Air-Gap Mode (Bundle) + +For offline deployments: + +1. Download EPSS CSV from FIRST.org on an internet-connected system +2. Copy to the configured `BundlePath` location +3. Set `SourceType: "bundle"` in configuration +4. The job will read from the local file instead of fetching online + +### 10.4 Manual Ingestion + +Trigger manual ingestion via the Scanner Worker API: + +```bash +# POST to trigger immediate ingestion for a specific date +curl -X POST "https://scanner-worker/epss/ingest?date=2025-12-18" +``` + +### 10.5 Troubleshooting + +| Symptom | Likely Cause | Resolution | +|---------|--------------|------------| +| Job not running | `Enabled: false` | Set `Enabled: true` | +| Download fails | Network/firewall | Check HTTPS egress to `epss.empiricalsecurity.com` | +| Parse errors | Corrupted file | Re-download, check SHA256 | +| Slow ingestion | Large dataset | Normal for ~250k rows; expect 60-90s | +| Duplicate runs | Idempotent | Safe - existing data preserved | + +### 10.6 Monitoring + +Key metrics and traces: + +- **Activity**: `StellaOps.Scanner.EpssIngest` with tags: + - `epss.model_date`: Date of EPSS model + - `epss.row_count`: Number of rows ingested + - `epss.cve_count`: Distinct CVEs processed + - `epss.duration_ms`: Total ingestion time + +- **Logs**: Structured logs at Info/Warning/Error levels + - `EPSS ingest job started` + - `Starting EPSS ingestion for {ModelDate}` + - `EPSS ingestion completed: modelDate={ModelDate}, rows={RowCount}...` + +--- + +## 11. Anti-Patterns to Avoid | Anti-Pattern | Why It's Wrong | |--------------|----------------| diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexEntry.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexEntry.cs new file mode 100644 index 00000000..b76bcd09 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexEntry.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.Analyzers.Native.Index; + +/// +/// NDJSON format for Build-ID index entries. +/// Each line is one JSON object in this format. +/// +public sealed class BuildIdIndexEntry +{ + /// + /// The Build-ID with prefix (e.g., "gnu-build-id:abc123", "pe-cv:guid-age", "macho-uuid:xyz"). + /// + [JsonPropertyName("build_id")] + public required string BuildId { get; init; } + + /// + /// Package URL for the binary. + /// + [JsonPropertyName("purl")] + public required string Purl { get; init; } + + /// + /// Package version (extracted from PURL if not provided). + /// + [JsonPropertyName("version")] + public string? Version { get; init; } + + /// + /// Source distribution (debian, ubuntu, alpine, fedora, etc.). + /// + [JsonPropertyName("distro")] + public string? Distro { get; init; } + + /// + /// Confidence level: "exact", "inferred", or "heuristic". + /// + [JsonPropertyName("confidence")] + public string Confidence { get; init; } = "exact"; + + /// + /// When this entry was indexed (ISO-8601). + /// + [JsonPropertyName("indexed_at")] + public DateTimeOffset? IndexedAt { get; init; } + + /// + /// Convert to lookup result. + /// + public BuildIdLookupResult ToLookupResult() => new( + BuildId, + Purl, + Version, + Distro, + ParseConfidence(Confidence), + IndexedAt ?? DateTimeOffset.MinValue); + + private static BuildIdConfidence ParseConfidence(string? value) => value?.ToLowerInvariant() switch + { + "exact" => BuildIdConfidence.Exact, + "inferred" => BuildIdConfidence.Inferred, + "heuristic" => BuildIdConfidence.Heuristic, + _ => BuildIdConfidence.Heuristic + }; +} diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexOptions.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexOptions.cs new file mode 100644 index 00000000..f199b8cb --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdIndexOptions.cs @@ -0,0 +1,38 @@ +namespace StellaOps.Scanner.Analyzers.Native.Index; + +/// +/// Configuration options for the Build-ID index. +/// +public sealed class BuildIdIndexOptions +{ + /// + /// Path to the offline NDJSON index file. + /// + public string? IndexPath { get; set; } + + /// + /// Path to the DSSE signature file for the index. + /// + public string? SignaturePath { get; set; } + + /// + /// Whether to require DSSE signature verification. + /// Defaults to true in production. + /// + public bool RequireSignature { get; set; } = true; + + /// + /// Maximum age of the index before warning (for freshness checks). + /// + public TimeSpan MaxIndexAge { get; set; } = TimeSpan.FromDays(30); + + /// + /// Whether to enable in-memory caching of index entries. + /// + public bool EnableCache { get; set; } = true; + + /// + /// Maximum number of entries to cache in memory. + /// + public int MaxCacheEntries { get; set; } = 100_000; +} diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdLookupResult.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdLookupResult.cs new file mode 100644 index 00000000..1bcd4746 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/BuildIdLookupResult.cs @@ -0,0 +1,39 @@ +namespace StellaOps.Scanner.Analyzers.Native.Index; + +/// +/// Confidence level for Build-ID to PURL mappings. +/// +public enum BuildIdConfidence +{ + /// + /// Exact match from official distro metadata or verified source. + /// + Exact, + + /// + /// Inferred from package metadata with high confidence. + /// + Inferred, + + /// + /// Best-guess heuristic (version pattern matching, etc.). + /// + Heuristic +} + +/// +/// Result of a Build-ID lookup. +/// +/// The queried Build-ID (ELF build-id, PE GUID+Age, Mach-O UUID). +/// Package URL for the binary. +/// Package version if known. +/// Source distribution (debian, alpine, fedora, etc.). +/// Confidence level of the match. +/// When this mapping was indexed. +public sealed record BuildIdLookupResult( + string BuildId, + string Purl, + string? Version, + string? SourceDistro, + BuildIdConfidence Confidence, + DateTimeOffset IndexedAt); diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/IBuildIdIndex.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/IBuildIdIndex.cs new file mode 100644 index 00000000..573f7711 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/IBuildIdIndex.cs @@ -0,0 +1,42 @@ +namespace StellaOps.Scanner.Analyzers.Native.Index; + +/// +/// Interface for Build-ID to PURL index lookups. +/// Enables binary identification in distroless/scratch images. +/// +public interface IBuildIdIndex +{ + /// + /// Look up a single Build-ID. + /// + /// The Build-ID to look up (e.g., "gnu-build-id:abc123", "pe-cv:guid-age", "macho-uuid:xyz"). + /// Cancellation token. + /// Lookup result if found; null otherwise. + Task LookupAsync(string buildId, CancellationToken cancellationToken = default); + + /// + /// Look up multiple Build-IDs efficiently. + /// + /// Build-IDs to look up. + /// Cancellation token. + /// Found results (unfound IDs are not included). + Task> BatchLookupAsync( + IEnumerable buildIds, + CancellationToken cancellationToken = default); + + /// + /// Gets the number of entries in the index. + /// + int Count { get; } + + /// + /// Gets whether the index has been loaded. + /// + bool IsLoaded { get; } + + /// + /// Load or reload the index from the configured source. + /// + /// Cancellation token. + Task LoadAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/OfflineBuildIdIndex.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/OfflineBuildIdIndex.cs new file mode 100644 index 00000000..54c44641 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/Index/OfflineBuildIdIndex.cs @@ -0,0 +1,207 @@ +using System.Collections.Frozen; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Scanner.Analyzers.Native.Index; + +/// +/// Offline Build-ID index that loads from NDJSON files. +/// Enables binary identification in distroless/scratch images. +/// +public sealed class OfflineBuildIdIndex : IBuildIdIndex +{ + private readonly BuildIdIndexOptions _options; + private readonly ILogger _logger; + private FrozenDictionary _index = FrozenDictionary.Empty; + private bool _isLoaded; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + /// + /// Creates a new offline Build-ID index. + /// + public OfflineBuildIdIndex(IOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + _options = options.Value; + _logger = logger; + } + + /// + public int Count => _index.Count; + + /// + public bool IsLoaded => _isLoaded; + + /// + public Task LookupAsync(string buildId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(buildId)) + { + return Task.FromResult(null); + } + + // Normalize Build-ID (lowercase, trim) + var normalized = NormalizeBuildId(buildId); + var result = _index.TryGetValue(normalized, out var entry) ? entry : null; + + return Task.FromResult(result); + } + + /// + public Task> BatchLookupAsync( + IEnumerable buildIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(buildIds); + + var results = new List(); + + foreach (var buildId in buildIds) + { + if (string.IsNullOrWhiteSpace(buildId)) + { + continue; + } + + var normalized = NormalizeBuildId(buildId); + if (_index.TryGetValue(normalized, out var entry)) + { + results.Add(entry); + } + } + + return Task.FromResult>(results); + } + + /// + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.IndexPath)) + { + _logger.LogWarning("No Build-ID index path configured; index will be empty"); + _index = FrozenDictionary.Empty; + _isLoaded = true; + return; + } + + if (!File.Exists(_options.IndexPath)) + { + _logger.LogWarning("Build-ID index file not found at {IndexPath}; index will be empty", _options.IndexPath); + _index = FrozenDictionary.Empty; + _isLoaded = true; + return; + } + + // TODO: BID-006 - Verify DSSE signature if RequireSignature is true + + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + var lineNumber = 0; + var errorCount = 0; + + await using var stream = File.OpenRead(_options.IndexPath); + using var reader = new StreamReader(stream); + + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + lineNumber++; + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // Skip comment lines (for manifest headers) + if (line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal)) + { + continue; + } + + try + { + var entry = JsonSerializer.Deserialize(line, JsonOptions); + if (entry is null || string.IsNullOrWhiteSpace(entry.BuildId) || string.IsNullOrWhiteSpace(entry.Purl)) + { + errorCount++; + continue; + } + + var normalized = NormalizeBuildId(entry.BuildId); + entries[normalized] = entry.ToLookupResult(); + } + catch (JsonException ex) + { + errorCount++; + if (errorCount <= 10) + { + _logger.LogWarning(ex, "Failed to parse Build-ID index line {LineNumber}", lineNumber); + } + } + } + + if (errorCount > 0) + { + _logger.LogWarning("Build-ID index had {ErrorCount} parse errors out of {TotalLines} lines", errorCount, lineNumber); + } + + _index = entries.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + _isLoaded = true; + + _logger.LogInformation("Loaded Build-ID index with {EntryCount} entries from {IndexPath}", _index.Count, _options.IndexPath); + + // Check index freshness + if (_options.MaxIndexAge > TimeSpan.Zero) + { + var oldestAllowed = DateTimeOffset.UtcNow - _options.MaxIndexAge; + var latestEntry = entries.Values.MaxBy(e => e.IndexedAt); + if (latestEntry is not null && latestEntry.IndexedAt < oldestAllowed) + { + _logger.LogWarning( + "Build-ID index may be stale. Latest entry from {LatestDate}, max age is {MaxAge}", + latestEntry.IndexedAt, + _options.MaxIndexAge); + } + } + } + + /// + /// Normalize a Build-ID for consistent lookup. + /// + private static string NormalizeBuildId(string buildId) + { + // Lowercase the entire string for case-insensitive matching + var normalized = buildId.Trim().ToLowerInvariant(); + + // Ensure consistent prefix format + // ELF: "gnu-build-id:..." or just the hex + // PE: "pe-cv:..." or "pe:guid-age" + // Mach-O: "macho-uuid:..." or just the hex + + // If no prefix, try to detect format from length/pattern + if (!normalized.Contains(':')) + { + // 32 hex chars = Mach-O UUID (128 bits) + // 40 hex chars = ELF SHA-1 build-id + // GUID+Age pattern for PE + if (normalized.Length == 32 && IsHex(normalized)) + { + // Could be Mach-O UUID or short ELF build-id + normalized = $"build-id:{normalized}"; + } + else if (normalized.Length == 40 && IsHex(normalized)) + { + normalized = $"gnu-build-id:{normalized}"; + } + } + + return normalized; + } + + private static bool IsHex(string s) => s.All(c => char.IsAsciiHexDigit(c)); +} diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOCodeSignature.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOCodeSignature.cs new file mode 100644 index 00000000..7c75dd1d --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOCodeSignature.cs @@ -0,0 +1,16 @@ +namespace StellaOps.Scanner.Analyzers.Native; + +/// +/// Code signature information from LC_CODE_SIGNATURE. +/// +/// Team identifier (10-character Apple team ID). +/// Signing identifier (usually bundle ID). +/// Code Directory hash (SHA-256, lowercase hex). +/// Whether hardened runtime is enabled. +/// Entitlements keys (not values, for privacy). +public sealed record MachOCodeSignature( + string? TeamId, + string? SigningId, + string? CdHash, + bool HasHardenedRuntime, + IReadOnlyList Entitlements); diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOIdentity.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOIdentity.cs new file mode 100644 index 00000000..bdc42037 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOIdentity.cs @@ -0,0 +1,24 @@ +namespace StellaOps.Scanner.Analyzers.Native; + +/// +/// Full identity information extracted from a Mach-O file. +/// +/// CPU type (x86_64, arm64, etc.). +/// CPU subtype for variant detection. +/// LC_UUID in lowercase hex (no dashes). +/// Whether this is a fat/universal binary. +/// Platform from LC_BUILD_VERSION. +/// Minimum OS version from LC_VERSION_MIN_* or LC_BUILD_VERSION. +/// SDK version from LC_BUILD_VERSION. +/// Code signature information (if signed). +/// Exported symbols from LC_DYLD_INFO_ONLY or LC_DYLD_EXPORTS_TRIE. +public sealed record MachOIdentity( + string? CpuType, + uint CpuSubtype, + string? Uuid, + bool IsFatBinary, + MachOPlatform Platform, + string? MinOsVersion, + string? SdkVersion, + MachOCodeSignature? CodeSignature, + IReadOnlyList Exports); diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOPlatform.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOPlatform.cs new file mode 100644 index 00000000..0caa5af7 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOPlatform.cs @@ -0,0 +1,46 @@ +namespace StellaOps.Scanner.Analyzers.Native; + +/// +/// Mach-O platform values from LC_BUILD_VERSION. +/// +public enum MachOPlatform : uint +{ + /// Unknown platform. + Unknown = 0, + + /// macOS. + MacOS = 1, + + /// iOS. + iOS = 2, + + /// tvOS. + TvOS = 3, + + /// watchOS. + WatchOS = 4, + + /// BridgeOS. + BridgeOS = 5, + + /// Mac Catalyst (iPad apps on Mac). + MacCatalyst = 6, + + /// iOS Simulator. + iOSSimulator = 7, + + /// tvOS Simulator. + TvOSSimulator = 8, + + /// watchOS Simulator. + WatchOSSimulator = 9, + + /// DriverKit. + DriverKit = 10, + + /// visionOS. + VisionOS = 11, + + /// visionOS Simulator. + VisionOSSimulator = 12 +} diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOReader.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOReader.cs new file mode 100644 index 00000000..5ba198dd --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/MachOReader.cs @@ -0,0 +1,640 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace StellaOps.Scanner.Analyzers.Native; + +/// +/// Result from parsing a Mach-O file. +/// +/// File path. +/// Container layer digest if applicable. +/// List of identities (one per slice in fat binary). +public sealed record MachOParseResult( + string Path, + string? LayerDigest, + IReadOnlyList Identities); + +/// +/// Full Mach-O file reader with identity extraction. +/// Handles both single-arch and fat (universal) binaries. +/// +public static class MachOReader +{ + // Mach-O magic numbers + private const uint MH_MAGIC = 0xFEEDFACE; // 32-bit, native endian + private const uint MH_CIGAM = 0xCEFAEDFE; // 32-bit, reversed endian + private const uint MH_MAGIC_64 = 0xFEEDFACF; // 64-bit, native endian + private const uint MH_CIGAM_64 = 0xCFFAEDFE; // 64-bit, reversed endian + + // Fat binary magic numbers + private const uint FAT_MAGIC = 0xCAFEBABE; // Big-endian + private const uint FAT_CIGAM = 0xBEBAFECA; // Little-endian + + // Load command types + private const uint LC_UUID = 0x1B; + private const uint LC_CODE_SIGNATURE = 0x1D; + private const uint LC_VERSION_MIN_MACOSX = 0x24; + private const uint LC_VERSION_MIN_IPHONEOS = 0x25; + private const uint LC_VERSION_MIN_WATCHOS = 0x30; + private const uint LC_VERSION_MIN_TVOS = 0x2F; + private const uint LC_BUILD_VERSION = 0x32; + private const uint LC_DYLD_INFO = 0x22; + private const uint LC_DYLD_INFO_ONLY = 0x80000022; + private const uint LC_DYLD_EXPORTS_TRIE = 0x80000033; + + // Code signature blob types + private const uint CSMAGIC_CODEDIRECTORY = 0xFADE0C02; + private const uint CSMAGIC_EMBEDDED_SIGNATURE = 0xFADE0CC0; + private const uint CSMAGIC_EMBEDDED_ENTITLEMENTS = 0xFADE7171; + + // CPU types + private const int CPU_TYPE_X86 = 7; + private const int CPU_TYPE_X86_64 = CPU_TYPE_X86 | 0x01000000; + private const int CPU_TYPE_ARM = 12; + private const int CPU_TYPE_ARM64 = CPU_TYPE_ARM | 0x01000000; + + /// + /// Parse a Mach-O file and extract full identity information. + /// For fat binaries, returns identities for all slices. + /// + public static MachOParseResult? Parse(Stream stream, string path, string? layerDigest = null) + { + if (!TryReadBytes(stream, 4, out var magicBytes)) + { + return null; + } + + stream.Position = 0; + var magic = BinaryPrimitives.ReadUInt32BigEndian(magicBytes); + + // Check for fat binary + if (magic is FAT_MAGIC or FAT_CIGAM) + { + var identities = ParseFatBinary(stream); + return identities.Count > 0 + ? new MachOParseResult(path, layerDigest, identities) + : null; + } + + // Single architecture binary + var identity = ParseSingleMachO(stream); + return identity is not null + ? new MachOParseResult(path, layerDigest, [identity]) + : null; + } + + /// + /// Try to extract just the identity without full parsing. + /// + public static bool TryExtractIdentity(Stream stream, out MachOIdentity? identity) + { + identity = null; + + if (!TryReadBytes(stream, 4, out var magicBytes)) + { + return false; + } + + stream.Position = 0; + var magic = BinaryPrimitives.ReadUInt32BigEndian(magicBytes); + + // Skip fat binary quick extraction for now + if (magic is FAT_MAGIC or FAT_CIGAM) + { + var identities = ParseFatBinary(stream); + identity = identities.Count > 0 ? identities[0] : null; + return identity is not null; + } + + identity = ParseSingleMachO(stream); + return identity is not null; + } + + /// + /// Parse a fat binary and return all slice identities. + /// + public static IReadOnlyList ParseFatBinary(Stream stream) + { + var identities = new List(); + + if (!TryReadBytes(stream, 8, out var headerBytes)) + { + return identities; + } + + var magic = BinaryPrimitives.ReadUInt32BigEndian(headerBytes); + var swapBytes = magic == FAT_CIGAM; + var nfatArch = swapBytes + ? BinaryPrimitives.ReadUInt32LittleEndian(headerBytes.AsSpan(4)) + : BinaryPrimitives.ReadUInt32BigEndian(headerBytes.AsSpan(4)); + + if (nfatArch > 100) + { + // Sanity check + return identities; + } + + for (var i = 0; i < nfatArch; i++) + { + if (!TryReadBytes(stream, 20, out var archBytes)) + { + break; + } + + // Fat arch structure is always big-endian (unless FAT_CIGAM) + uint offset, size; + if (swapBytes) + { + // cputype(4), cpusubtype(4), offset(4), size(4), align(4) + offset = BinaryPrimitives.ReadUInt32LittleEndian(archBytes.AsSpan(8)); + size = BinaryPrimitives.ReadUInt32LittleEndian(archBytes.AsSpan(12)); + } + else + { + offset = BinaryPrimitives.ReadUInt32BigEndian(archBytes.AsSpan(8)); + size = BinaryPrimitives.ReadUInt32BigEndian(archBytes.AsSpan(12)); + } + + // Save position and parse the embedded Mach-O + var currentPos = stream.Position; + stream.Position = offset; + + var sliceIdentity = ParseSingleMachO(stream, isFatSlice: true); + if (sliceIdentity is not null) + { + identities.Add(sliceIdentity); + } + + stream.Position = currentPos; + } + + return identities; + } + + /// + /// Parse a single Mach-O binary (not fat). + /// + private static MachOIdentity? ParseSingleMachO(Stream stream, bool isFatSlice = false) + { + var startOffset = stream.Position; + + if (!TryReadBytes(stream, 4, out var magicBytes)) + { + return null; + } + + var magic = BinaryPrimitives.ReadUInt32LittleEndian(magicBytes); + bool is64Bit; + bool swapBytes; + + switch (magic) + { + case MH_MAGIC: + is64Bit = false; + swapBytes = false; + break; + case MH_CIGAM: + is64Bit = false; + swapBytes = true; + break; + case MH_MAGIC_64: + is64Bit = true; + swapBytes = false; + break; + case MH_CIGAM_64: + is64Bit = true; + swapBytes = true; + break; + default: + return null; + } + + // Read rest of Mach header + var headerSize = is64Bit ? 32 : 28; + stream.Position = startOffset; + + if (!TryReadBytes(stream, headerSize, out var headerBytes)) + { + return null; + } + + // Parse header + var cpuType = ReadInt32(headerBytes, 4, swapBytes); + var cpuSubtype = ReadUInt32(headerBytes, 8, swapBytes); + var ncmds = ReadUInt32(headerBytes, 16, swapBytes); + var sizeofcmds = ReadUInt32(headerBytes, 20, swapBytes); + + var cpuTypeName = GetCpuTypeName(cpuType); + + // Initialize identity fields + string? uuid = null; + var platform = MachOPlatform.Unknown; + string? minOsVersion = null; + string? sdkVersion = null; + MachOCodeSignature? codeSignature = null; + var exports = new List(); + + // Read load commands + var loadCommandsStart = stream.Position; + var loadCommandsEnd = loadCommandsStart + sizeofcmds; + + for (uint cmd = 0; cmd < ncmds && stream.Position < loadCommandsEnd; cmd++) + { + if (!TryReadBytes(stream, 8, out var cmdHeader)) + { + break; + } + + var cmdType = ReadUInt32(cmdHeader, 0, swapBytes); + var cmdSize = ReadUInt32(cmdHeader, 4, swapBytes); + + if (cmdSize < 8) + { + break; + } + + var cmdDataSize = (int)cmdSize - 8; + + switch (cmdType) + { + case LC_UUID when cmdDataSize >= 16: + if (TryReadBytes(stream, 16, out var uuidBytes)) + { + uuid = Convert.ToHexStringLower(uuidBytes); + } + + stream.Position = loadCommandsStart + GetNextCmdOffset(cmd, ncmds, stream.Position - loadCommandsStart, cmdSize); + continue; + + case LC_BUILD_VERSION when cmdDataSize >= 16: + if (TryReadBytes(stream, cmdDataSize, out var buildVersionBytes)) + { + var platformValue = ReadUInt32(buildVersionBytes, 0, swapBytes); + platform = (MachOPlatform)platformValue; + + var minos = ReadUInt32(buildVersionBytes, 4, swapBytes); + minOsVersion = FormatVersion(minos); + + var sdk = ReadUInt32(buildVersionBytes, 8, swapBytes); + sdkVersion = FormatVersion(sdk); + } + + continue; + + case LC_VERSION_MIN_MACOSX: + case LC_VERSION_MIN_IPHONEOS: + case LC_VERSION_MIN_WATCHOS: + case LC_VERSION_MIN_TVOS: + if (TryReadBytes(stream, cmdDataSize, out var versionMinBytes)) + { + if (platform == MachOPlatform.Unknown) + { + platform = cmdType switch + { + LC_VERSION_MIN_MACOSX => MachOPlatform.MacOS, + LC_VERSION_MIN_IPHONEOS => MachOPlatform.iOS, + LC_VERSION_MIN_WATCHOS => MachOPlatform.WatchOS, + LC_VERSION_MIN_TVOS => MachOPlatform.TvOS, + _ => MachOPlatform.Unknown + }; + } + + if (versionMinBytes.Length >= 8) + { + var version = ReadUInt32(versionMinBytes, 0, swapBytes); + if (minOsVersion is null) + { + minOsVersion = FormatVersion(version); + } + + var sdk = ReadUInt32(versionMinBytes, 4, swapBytes); + if (sdkVersion is null) + { + sdkVersion = FormatVersion(sdk); + } + } + } + + continue; + + case LC_CODE_SIGNATURE: + if (TryReadBytes(stream, cmdDataSize, out var codeSignBytes) && codeSignBytes.Length >= 8) + { + var dataOff = ReadUInt32(codeSignBytes, 0, swapBytes); + var dataSize = ReadUInt32(codeSignBytes, 4, swapBytes); + + // Parse code signature at offset + var currentPos = stream.Position; + stream.Position = startOffset + dataOff; + + codeSignature = ParseCodeSignature(stream, (int)dataSize); + + stream.Position = currentPos; + } + + continue; + } + + // Skip remaining bytes of command + var remaining = cmdDataSize - (stream.Position - loadCommandsStart - 8); + if (remaining > 0) + { + stream.Position += remaining; + } + } + + return new MachOIdentity( + cpuTypeName, + cpuSubtype, + uuid, + isFatSlice, + platform, + minOsVersion, + sdkVersion, + codeSignature, + exports); + } + + /// + /// Parse the code signature blob. + /// + private static MachOCodeSignature? ParseCodeSignature(Stream stream, int size) + { + if (!TryReadBytes(stream, 8, out var superBlobHeader)) + { + return null; + } + + var magic = BinaryPrimitives.ReadUInt32BigEndian(superBlobHeader); + if (magic != CSMAGIC_EMBEDDED_SIGNATURE) + { + return null; + } + + var length = BinaryPrimitives.ReadUInt32BigEndian(superBlobHeader.AsSpan(4)); + if (length > size || length < 12) + { + return null; + } + + if (!TryReadBytes(stream, 4, out var countBytes)) + { + return null; + } + + var count = BinaryPrimitives.ReadUInt32BigEndian(countBytes); + if (count > 100) + { + return null; + } + + var blobStart = stream.Position - 12; + + // Read blob index entries + var blobs = new List<(uint type, uint offset)>(); + for (uint i = 0; i < count; i++) + { + if (!TryReadBytes(stream, 8, out var indexEntry)) + { + break; + } + + var blobType = BinaryPrimitives.ReadUInt32BigEndian(indexEntry); + var blobOffset = BinaryPrimitives.ReadUInt32BigEndian(indexEntry.AsSpan(4)); + blobs.Add((blobType, blobOffset)); + } + + string? teamId = null; + string? signingId = null; + string? cdHash = null; + var hasHardenedRuntime = false; + var entitlements = new List(); + + foreach (var (blobType, blobOffset) in blobs) + { + stream.Position = blobStart + blobOffset; + + if (!TryReadBytes(stream, 8, out var blobHeader)) + { + continue; + } + + var blobMagic = BinaryPrimitives.ReadUInt32BigEndian(blobHeader); + var blobLength = BinaryPrimitives.ReadUInt32BigEndian(blobHeader.AsSpan(4)); + + switch (blobMagic) + { + case CSMAGIC_CODEDIRECTORY: + (teamId, signingId, cdHash, hasHardenedRuntime) = ParseCodeDirectory(stream, blobStart + blobOffset, (int)blobLength); + break; + + case CSMAGIC_EMBEDDED_ENTITLEMENTS: + entitlements = ParseEntitlements(stream, (int)blobLength - 8); + break; + } + } + + if (teamId is null && signingId is null && cdHash is null) + { + return null; + } + + return new MachOCodeSignature(teamId, signingId, cdHash, hasHardenedRuntime, entitlements); + } + + /// + /// Parse CodeDirectory blob. + /// + private static (string? TeamId, string? SigningId, string? CdHash, bool HasHardenedRuntime) ParseCodeDirectory( + Stream stream, long blobStart, int length) + { + // CodeDirectory has a complex structure, we'll extract key fields + stream.Position = blobStart; + + if (!TryReadBytes(stream, Math.Min(length, 52), out var cdBytes)) + { + return (null, null, null, false); + } + + // Offsets in CodeDirectory (all big-endian) + // +8: version + // +12: flags + // +16: hashOffset + // +20: identOffset + // +28: nCodeSlots + // +32: codeLimit + // +36: hashSize + // +37: hashType + // +38: platform + // +39: pageSize + // +44: spare2 + // +48: scatterOffset (v2+) + // +52: teamOffset (v2+) + + var version = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(8)); + var flags = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(12)); + var identOffset = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(20)); + + // Check for hardened runtime (flag 0x10000) + var hasHardenedRuntime = (flags & 0x10000) != 0; + + // Read signing identifier + string? signingId = null; + if (identOffset > 0 && identOffset < length) + { + stream.Position = blobStart + identOffset; + signingId = ReadNullTerminatedString(stream, 256); + } + + // Read team ID (version 0x20200 and later) + string? teamId = null; + if (version >= 0x20200 && cdBytes.Length >= 56) + { + var teamOffset = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(52)); + if (teamOffset > 0 && teamOffset < length) + { + stream.Position = blobStart + teamOffset; + teamId = ReadNullTerminatedString(stream, 20); + } + } + + // Compute CDHash (SHA-256 of the entire CodeDirectory blob) + stream.Position = blobStart; + if (TryReadBytes(stream, length, out var fullCdBytes)) + { + var hash = SHA256.HashData(fullCdBytes); + var cdHash = Convert.ToHexStringLower(hash); + return (teamId, signingId, cdHash, hasHardenedRuntime); + } + + return (teamId, signingId, null, hasHardenedRuntime); + } + + /// + /// Parse entitlements plist and extract keys. + /// + private static List ParseEntitlements(Stream stream, int length) + { + var keys = new List(); + + if (!TryReadBytes(stream, length, out var plistBytes)) + { + return keys; + } + + // Simple plist key extraction (looks for ... patterns) + var plist = Encoding.UTF8.GetString(plistBytes); + + var keyStart = 0; + while ((keyStart = plist.IndexOf("", keyStart, StringComparison.Ordinal)) >= 0) + { + keyStart += 5; + var keyEnd = plist.IndexOf("", keyStart, StringComparison.Ordinal); + if (keyEnd > keyStart) + { + var key = plist[keyStart..keyEnd]; + if (!string.IsNullOrWhiteSpace(key)) + { + keys.Add(key); + } + + keyStart = keyEnd + 6; + } + else + { + break; + } + } + + return keys; + } + + /// + /// Get CPU type name from CPU type value. + /// + private static string? GetCpuTypeName(int cpuType) => cpuType switch + { + CPU_TYPE_X86 => "i386", + CPU_TYPE_X86_64 => "x86_64", + CPU_TYPE_ARM => "arm", + CPU_TYPE_ARM64 => "arm64", + _ => $"cpu_{cpuType}" + }; + + /// + /// Format version number (major.minor.patch from packed uint32). + /// + private static string FormatVersion(uint version) + { + var major = (version >> 16) & 0xFFFF; + var minor = (version >> 8) & 0xFF; + var patch = version & 0xFF; + return patch == 0 ? $"{major}.{minor}" : $"{major}.{minor}.{patch}"; + } + + /// + /// Read a null-terminated string from stream. + /// + private static string? ReadNullTerminatedString(Stream stream, int maxLength) + { + var bytes = new byte[maxLength]; + var count = 0; + + while (count < maxLength) + { + var b = stream.ReadByte(); + if (b <= 0) + { + break; + } + + bytes[count++] = (byte)b; + } + + return count > 0 ? Encoding.UTF8.GetString(bytes, 0, count) : null; + } + + /// + /// Try to read exactly the specified number of bytes. + /// + private static bool TryReadBytes(Stream stream, int count, out byte[] bytes) + { + bytes = new byte[count]; + var totalRead = 0; + while (totalRead < count) + { + var read = stream.Read(bytes, totalRead, count - totalRead); + if (read == 0) + { + return false; + } + + totalRead += read; + } + + return true; + } + + /// + /// Read int32 with optional byte swapping. + /// + private static int ReadInt32(byte[] data, int offset, bool swap) => + swap + ? BinaryPrimitives.ReadInt32BigEndian(data.AsSpan(offset)) + : BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset)); + + /// + /// Read uint32 with optional byte swapping. + /// + private static uint ReadUInt32(byte[] data, int offset, bool swap) => + swap + ? BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset)) + : BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset)); + + /// + /// Calculate the offset for the next load command. + /// + private static long GetNextCmdOffset(uint currentCmd, uint totalCmds, long currentOffset, uint cmdSize) => + currentOffset + cmdSize - 8; +} diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeBinaryIdentity.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeBinaryIdentity.cs index 1a7f8c30..49e0f0da 100644 --- a/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeBinaryIdentity.cs +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeBinaryIdentity.cs @@ -1,5 +1,23 @@ namespace StellaOps.Scanner.Analyzers.Native; +/// +/// Identity information extracted from a native binary (ELF, PE, Mach-O). +/// +/// Binary format (ELF, PE, Mach-O). +/// CPU architecture (x86, x86_64, arm64, etc.). +/// Target OS (linux, windows, darwin, etc.). +/// Byte order (le, be). +/// ELF GNU Build-ID (hex string). +/// Mach-O LC_UUID (hex string). +/// ELF interpreter path (e.g., /lib64/ld-linux-x86-64.so.2). +/// PE CodeView GUID (lowercase hex, no dashes). +/// PE CodeView Age (increments on rebuild). +/// PE version resource ProductVersion. +/// Mach-O platform (macOS, iOS, etc.). +/// Mach-O minimum OS version. +/// Mach-O SDK version. +/// Mach-O CodeDirectory hash (SHA-256). +/// Mach-O code signing Team ID. public sealed record NativeBinaryIdentity( NativeFormat Format, string? CpuArchitecture, @@ -7,4 +25,13 @@ public sealed record NativeBinaryIdentity( string? Endianness, string? BuildId, string? Uuid, - string? InterpreterPath); + string? InterpreterPath, + string? CodeViewGuid = null, + int? CodeViewAge = null, + string? ProductVersion = null, + MachOPlatform? MachOPlatform = null, + string? MachOMinOsVersion = null, + string? MachOSdkVersion = null, + string? MachOCdHash = null, + string? MachOTeamId = null); + diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeFormatDetector.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeFormatDetector.cs index 3329a6f6..8790458d 100644 --- a/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeFormatDetector.cs +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeFormatDetector.cs @@ -180,6 +180,24 @@ public static class NativeFormatDetector return false; } + // Try full PE parsing for CodeView GUID and other identity info + if (PeReader.TryExtractIdentity(span, out var peIdentity) && peIdentity is not null) + { + identity = new NativeBinaryIdentity( + NativeFormat.Pe, + peIdentity.Machine, + "windows", + Endianness: "le", + BuildId: null, + Uuid: null, + InterpreterPath: null, + CodeViewGuid: peIdentity.CodeViewGuid, + CodeViewAge: peIdentity.CodeViewAge, + ProductVersion: peIdentity.ProductVersion); + return true; + } + + // Fallback to basic parsing var machine = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(peHeaderOffset + 4, 2)); var arch = MapPeMachine(machine); @@ -205,6 +223,30 @@ public static class NativeFormatDetector return false; } + // Try full parsing with MachOReader + using var stream = new MemoryStream(span.ToArray()); + if (MachOReader.TryExtractIdentity(stream, out var machOIdentity) && machOIdentity is not null) + { + var endianness = magic is 0xCAFEBABE or 0xFEEDFACE or 0xFEEDFACF ? "be" : "le"; + var prefixedUuid = machOIdentity.Uuid is not null ? $"macho-uuid:{machOIdentity.Uuid}" : null; + + identity = new NativeBinaryIdentity( + NativeFormat.MachO, + machOIdentity.CpuType, + "darwin", + Endianness: endianness, + BuildId: prefixedUuid, + Uuid: prefixedUuid, + InterpreterPath: null, + MachOPlatform: machOIdentity.Platform, + MachOMinOsVersion: machOIdentity.MinOsVersion, + MachOSdkVersion: machOIdentity.SdkVersion, + MachOCdHash: machOIdentity.CodeSignature?.CdHash, + MachOTeamId: machOIdentity.CodeSignature?.TeamId); + return true; + } + + // Fallback to basic parsing bool bigEndian = magic is 0xCAFEBABE or 0xFEEDFACE or 0xFEEDFACF; uint cputype; @@ -229,7 +271,7 @@ public static class NativeFormatDetector } var arch = MapMachCpuType(cputype); - var endianness = bigEndian ? "be" : "le"; + var fallbackEndianness = bigEndian ? "be" : "le"; string? uuid = null; if (!isFat) @@ -269,7 +311,7 @@ public static class NativeFormatDetector } // Store Mach-O UUID in BuildId field (prefixed) and also in Uuid for backwards compatibility - identity = new NativeBinaryIdentity(NativeFormat.MachO, arch, "darwin", Endianness: endianness, BuildId: uuid, Uuid: uuid, InterpreterPath: null); + identity = new NativeBinaryIdentity(NativeFormat.MachO, arch, "darwin", Endianness: fallbackEndianness, BuildId: uuid, Uuid: uuid, InterpreterPath: null); return true; } diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeCompilerHint.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeCompilerHint.cs new file mode 100644 index 00000000..f29dbe26 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeCompilerHint.cs @@ -0,0 +1,12 @@ +namespace StellaOps.Scanner.Analyzers.Native; + +/// +/// Compiler/linker hint extracted from PE Rich Header. +/// +/// Tool ID (@comp.id) - identifies the compiler/linker. +/// Tool version (@prod.id) - identifies the version. +/// Number of times this tool was used. +public sealed record PeCompilerHint( + ushort ToolId, + ushort ToolVersion, + int UseCount); diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeIdentity.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeIdentity.cs new file mode 100644 index 00000000..ff860d77 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeIdentity.cs @@ -0,0 +1,34 @@ +namespace StellaOps.Scanner.Analyzers.Native; + +/// +/// Full identity information extracted from a PE (Portable Executable) file. +/// +/// Machine type (x86, x86_64, ARM64, etc.). +/// Whether this is a 64-bit PE (PE32+). +/// PE subsystem (Console, GUI, Native, etc.). +/// CodeView PDB70 GUID in lowercase hex (no dashes). +/// CodeView Age field (increments on rebuild). +/// Original PDB path from debug directory. +/// Product version from version resource. +/// File version from version resource. +/// Company name from version resource. +/// Product name from version resource. +/// Original filename from version resource. +/// Rich header hash (XOR of all entries). +/// Compiler hints from rich header. +/// Exported symbols from export directory. +public sealed record PeIdentity( + string? Machine, + bool Is64Bit, + PeSubsystem Subsystem, + string? CodeViewGuid, + int? CodeViewAge, + string? PdbPath, + string? ProductVersion, + string? FileVersion, + string? CompanyName, + string? ProductName, + string? OriginalFilename, + uint? RichHeaderHash, + IReadOnlyList CompilerHints, + IReadOnlyList Exports); diff --git a/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeReader.cs b/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeReader.cs new file mode 100644 index 00000000..ed020706 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Analyzers.Native/PeReader.cs @@ -0,0 +1,757 @@ +using System.Buffers.Binary; +using System.Text; + +namespace StellaOps.Scanner.Analyzers.Native; + +/// +/// Full PE file reader with identity extraction including CodeView GUID, Rich header, and version resources. +/// +public static class PeReader +{ + // PE Data Directory Indices + private const int IMAGE_DIRECTORY_ENTRY_EXPORT = 0; + private const int IMAGE_DIRECTORY_ENTRY_DEBUG = 6; + private const int IMAGE_DIRECTORY_ENTRY_RESOURCE = 2; + + // Debug Types + private const uint IMAGE_DEBUG_TYPE_CODEVIEW = 2; + + // CodeView Signatures + private const uint RSDS_SIGNATURE = 0x53445352; // "RSDS" in little-endian + + // Rich Header Markers + private const uint RICH_MARKER = 0x68636952; // "Rich" in little-endian + private const uint DANS_MARKER = 0x536E6144; // "DanS" in little-endian + + /// + /// Parse result containing identity and any parsing metadata. + /// + public sealed record PeParseResult( + PeIdentity Identity, + string? ParseWarning); + + /// + /// Parse a PE file and extract full identity information. + /// + /// Stream containing PE file data. + /// File path for context (not accessed). + /// Optional container layer digest. + /// Parse result, or null if not a valid PE file. + public static PeParseResult? Parse(Stream stream, string path, string? layerDigest = null) + { + ArgumentNullException.ThrowIfNull(stream); + + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + var data = buffer.ToArray(); + + if (!TryExtractIdentity(data, out var identity) || identity is null) + { + return null; + } + + return new PeParseResult(identity, null); + } + + /// + /// Try to extract identity from PE file data. + /// + /// PE file bytes. + /// Extracted identity if successful. + /// True if valid PE file, false otherwise. + public static bool TryExtractIdentity(ReadOnlySpan data, out PeIdentity? identity) + { + identity = null; + + // Validate DOS header + if (!ValidateDosHeader(data, out var peHeaderOffset)) + { + return false; + } + + // Validate PE signature + if (!ValidatePeSignature(data, peHeaderOffset)) + { + return false; + } + + // Parse COFF header + if (!ParseCoffHeader(data, peHeaderOffset, out var machine, out var numberOfSections, out var sizeOfOptionalHeader)) + { + return false; + } + + // Parse Optional header + if (!ParseOptionalHeader(data, peHeaderOffset, sizeOfOptionalHeader, + out var is64Bit, out var subsystem, out var numberOfRvaAndSizes, out var dataDirectoryOffset)) + { + return false; + } + + var machineStr = MapPeMachine(machine); + + // Parse section headers for RVA-to-file-offset translation + var sectionHeadersOffset = peHeaderOffset + 24 + sizeOfOptionalHeader; + var sections = ParseSectionHeaders(data, sectionHeadersOffset, numberOfSections); + + // Extract Rich header (before PE header in DOS stub) + uint? richHeaderHash = null; + var compilerHints = new List(); + ParseRichHeader(data, peHeaderOffset, out richHeaderHash, compilerHints); + + // Extract CodeView debug info + string? codeViewGuid = null; + int? codeViewAge = null; + string? pdbPath = null; + if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_DEBUG) + { + ParseDebugDirectory(data, dataDirectoryOffset, numberOfRvaAndSizes, sections, + out codeViewGuid, out codeViewAge, out pdbPath); + } + + // Extract version resources + string? productVersion = null; + string? fileVersion = null; + string? companyName = null; + string? productName = null; + string? originalFilename = null; + if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_RESOURCE) + { + ParseVersionResource(data, dataDirectoryOffset, sections, is64Bit, + out productVersion, out fileVersion, out companyName, out productName, out originalFilename); + } + + // Extract exports + var exports = new List(); + if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_EXPORT) + { + ParseExportDirectory(data, dataDirectoryOffset, sections, exports); + } + + identity = new PeIdentity( + Machine: machineStr, + Is64Bit: is64Bit, + Subsystem: subsystem, + CodeViewGuid: codeViewGuid, + CodeViewAge: codeViewAge, + PdbPath: pdbPath, + ProductVersion: productVersion, + FileVersion: fileVersion, + CompanyName: companyName, + ProductName: productName, + OriginalFilename: originalFilename, + RichHeaderHash: richHeaderHash, + CompilerHints: compilerHints, + Exports: exports + ); + + return true; + } + + /// + /// Validate DOS header and extract PE header offset. + /// + private static bool ValidateDosHeader(ReadOnlySpan data, out int peHeaderOffset) + { + peHeaderOffset = 0; + + if (data.Length < 0x40) + { + return false; + } + + // Check MZ signature + if (data[0] != 'M' || data[1] != 'Z') + { + return false; + } + + // Read e_lfanew (offset to PE header) at offset 0x3C + peHeaderOffset = BinaryPrimitives.ReadInt32LittleEndian(data.Slice(0x3C, 4)); + + if (peHeaderOffset < 0 || peHeaderOffset + 24 > data.Length) + { + return false; + } + + return true; + } + + /// + /// Validate PE signature at the given offset. + /// + private static bool ValidatePeSignature(ReadOnlySpan data, int peHeaderOffset) + { + if (peHeaderOffset + 4 > data.Length) + { + return false; + } + + // Check "PE\0\0" signature + return data[peHeaderOffset] == 'P' + && data[peHeaderOffset + 1] == 'E' + && data[peHeaderOffset + 2] == 0 + && data[peHeaderOffset + 3] == 0; + } + + /// + /// Parse COFF header. + /// + private static bool ParseCoffHeader(ReadOnlySpan data, int peHeaderOffset, + out ushort machine, out ushort numberOfSections, out ushort sizeOfOptionalHeader) + { + machine = 0; + numberOfSections = 0; + sizeOfOptionalHeader = 0; + + var coffOffset = peHeaderOffset + 4; + if (coffOffset + 20 > data.Length) + { + return false; + } + + machine = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(coffOffset, 2)); + numberOfSections = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(coffOffset + 2, 2)); + sizeOfOptionalHeader = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(coffOffset + 16, 2)); + + return sizeOfOptionalHeader > 0; + } + + /// + /// Parse Optional header. + /// + private static bool ParseOptionalHeader(ReadOnlySpan data, int peHeaderOffset, ushort sizeOfOptionalHeader, + out bool is64Bit, out PeSubsystem subsystem, out uint numberOfRvaAndSizes, out int dataDirectoryOffset) + { + is64Bit = false; + subsystem = PeSubsystem.Unknown; + numberOfRvaAndSizes = 0; + dataDirectoryOffset = 0; + + var optionalHeaderOffset = peHeaderOffset + 24; + if (optionalHeaderOffset + sizeOfOptionalHeader > data.Length) + { + return false; + } + + var magic = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(optionalHeaderOffset, 2)); + is64Bit = magic == 0x20b; // PE32+ + + if (magic != 0x10b && magic != 0x20b) // PE32 or PE32+ + { + return false; + } + + // Subsystem offset: 68 for both PE32 and PE32+ + var subsystemOffset = optionalHeaderOffset + 68; + if (subsystemOffset + 2 <= data.Length) + { + subsystem = (PeSubsystem)BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(subsystemOffset, 2)); + } + + // NumberOfRvaAndSizes + var rvaAndSizesOffset = optionalHeaderOffset + (is64Bit ? 108 : 92); + if (rvaAndSizesOffset + 4 <= data.Length) + { + numberOfRvaAndSizes = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(rvaAndSizesOffset, 4)); + } + + // Data directories start after the numberOfRvaAndSizes field + dataDirectoryOffset = optionalHeaderOffset + (is64Bit ? 112 : 96); + + return true; + } + + /// + /// Parse section headers for RVA-to-file-offset translation. + /// + private static List ParseSectionHeaders(ReadOnlySpan data, int offset, ushort numberOfSections) + { + const int SECTION_HEADER_SIZE = 40; + var sections = new List(); + + for (var i = 0; i < numberOfSections; i++) + { + var entryOffset = offset + i * SECTION_HEADER_SIZE; + if (entryOffset + SECTION_HEADER_SIZE > data.Length) + { + break; + } + + var virtualSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 8, 4)); + var virtualAddress = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 12, 4)); + var rawDataSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 16, 4)); + var rawDataPointer = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 20, 4)); + + sections.Add(new SectionHeader(virtualAddress, virtualSize, rawDataPointer, rawDataSize)); + } + + return sections; + } + + /// + /// Convert RVA to file offset using section headers. + /// + private static bool TryRvaToFileOffset(uint rva, List sections, out uint fileOffset) + { + fileOffset = 0; + + foreach (var section in sections) + { + if (rva >= section.VirtualAddress && rva < section.VirtualAddress + section.VirtualSize) + { + fileOffset = rva - section.VirtualAddress + section.RawDataPointer; + return true; + } + } + + return false; + } + + /// + /// Parse Rich header from DOS stub. + /// + private static void ParseRichHeader(ReadOnlySpan data, int peHeaderOffset, + out uint? richHeaderHash, List compilerHints) + { + richHeaderHash = null; + + // Search for "Rich" marker backwards from PE header + var searchEnd = Math.Min(peHeaderOffset, data.Length); + var richOffset = -1; + + for (var i = searchEnd - 4; i >= 0x40; i--) + { + var marker = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i, 4)); + if (marker == RICH_MARKER) + { + richOffset = i; + break; + } + } + + if (richOffset < 0 || richOffset + 8 > data.Length) + { + return; + } + + // XOR key follows "Rich" marker + var xorKey = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(richOffset + 4, 4)); + richHeaderHash = xorKey; + + // Search backwards for "DanS" marker (XOR'd) + var dansOffset = -1; + for (var i = richOffset - 4; i >= 0x40; i -= 4) + { + if (i + 4 > data.Length) + { + continue; + } + + var value = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i, 4)); + if ((value ^ xorKey) == DANS_MARKER) + { + dansOffset = i; + break; + } + } + + if (dansOffset < 0) + { + return; + } + + // Parse entries between DanS and Rich (skip first 16 bytes after DanS which are padding) + var entriesStart = dansOffset + 16; + for (var i = entriesStart; i < richOffset; i += 8) + { + if (i + 8 > data.Length) + { + break; + } + + var compId = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i, 4)) ^ xorKey; + var useCount = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i + 4, 4)) ^ xorKey; + + if (compId == 0 && useCount == 0) + { + continue; + } + + var toolId = (ushort)(compId & 0xFFFF); + var toolVersion = (ushort)((compId >> 16) & 0xFFFF); + + compilerHints.Add(new PeCompilerHint(toolId, toolVersion, (int)useCount)); + } + } + + /// + /// Parse debug directory for CodeView GUID. + /// + private static void ParseDebugDirectory(ReadOnlySpan data, int dataDirectoryOffset, uint numberOfRvaAndSizes, + List sections, out string? codeViewGuid, out int? codeViewAge, out string? pdbPath) + { + codeViewGuid = null; + codeViewAge = null; + pdbPath = null; + + if (numberOfRvaAndSizes <= IMAGE_DIRECTORY_ENTRY_DEBUG) + { + return; + } + + var debugDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_DEBUG * 8; + if (debugDirOffset + 8 > data.Length) + { + return; + } + + var debugRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(debugDirOffset, 4)); + var debugSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(debugDirOffset + 4, 4)); + + if (debugRva == 0 || debugSize == 0) + { + return; + } + + if (!TryRvaToFileOffset(debugRva, sections, out var debugFileOffset)) + { + return; + } + + // Each debug directory entry is 28 bytes + const int DEBUG_ENTRY_SIZE = 28; + var numEntries = debugSize / DEBUG_ENTRY_SIZE; + + for (var i = 0; i < numEntries; i++) + { + var entryOffset = (int)debugFileOffset + i * DEBUG_ENTRY_SIZE; + if (entryOffset + DEBUG_ENTRY_SIZE > data.Length) + { + break; + } + + var debugType = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 12, 4)); + if (debugType != IMAGE_DEBUG_TYPE_CODEVIEW) + { + continue; + } + + var sizeOfData = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 16, 4)); + var pointerToRawData = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 24, 4)); + + if (pointerToRawData == 0 || sizeOfData < 24) + { + continue; + } + + if (pointerToRawData + sizeOfData > data.Length) + { + continue; + } + + var cvSpan = data.Slice((int)pointerToRawData, (int)sizeOfData); + + // Check for RSDS signature (PDB70) + var signature = BinaryPrimitives.ReadUInt32LittleEndian(cvSpan); + if (signature != RSDS_SIGNATURE) + { + continue; + } + + // GUID is 16 bytes at offset 4 + var guidBytes = cvSpan.Slice(4, 16); + codeViewGuid = FormatGuidAsLowercaseHex(guidBytes); + + // Age is 4 bytes at offset 20 + codeViewAge = (int)BinaryPrimitives.ReadUInt32LittleEndian(cvSpan.Slice(20, 4)); + + // PDB path is null-terminated string starting at offset 24 + var pdbPathSpan = cvSpan[24..]; + var nullTerminator = pdbPathSpan.IndexOf((byte)0); + var pathLength = nullTerminator >= 0 ? nullTerminator : pdbPathSpan.Length; + if (pathLength > 0) + { + pdbPath = Encoding.UTF8.GetString(pdbPathSpan[..pathLength]); + } + + break; // Found CodeView, done + } + } + + /// + /// Format GUID bytes as lowercase hex without dashes. + /// + private static string FormatGuidAsLowercaseHex(ReadOnlySpan guidBytes) + { + // GUID structure: Data1 (LE 4 bytes), Data2 (LE 2 bytes), Data3 (LE 2 bytes), Data4 (8 bytes BE) + var sb = new StringBuilder(32); + + // Data1 - 4 bytes, little endian + sb.Append(BinaryPrimitives.ReadUInt32LittleEndian(guidBytes).ToString("x8")); + // Data2 - 2 bytes, little endian + sb.Append(BinaryPrimitives.ReadUInt16LittleEndian(guidBytes.Slice(4, 2)).ToString("x4")); + // Data3 - 2 bytes, little endian + sb.Append(BinaryPrimitives.ReadUInt16LittleEndian(guidBytes.Slice(6, 2)).ToString("x4")); + // Data4 - 8 bytes, big endian (stored as-is) + for (var i = 8; i < 16; i++) + { + sb.Append(guidBytes[i].ToString("x2")); + } + + return sb.ToString(); + } + + /// + /// Parse version resource for product/file information. + /// + private static void ParseVersionResource(ReadOnlySpan data, int dataDirectoryOffset, + List sections, bool is64Bit, + out string? productVersion, out string? fileVersion, + out string? companyName, out string? productName, out string? originalFilename) + { + productVersion = null; + fileVersion = null; + companyName = null; + productName = null; + originalFilename = null; + + var resourceDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_RESOURCE * 8; + if (resourceDirOffset + 8 > data.Length) + { + return; + } + + var resourceRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(resourceDirOffset, 4)); + var resourceSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(resourceDirOffset + 4, 4)); + + if (resourceRva == 0 || resourceSize == 0) + { + return; + } + + if (!TryRvaToFileOffset(resourceRva, sections, out var resourceFileOffset)) + { + return; + } + + // Search for VS_VERSION_INFO signature in resources + // This is a simplified approach - searching for the signature in the resource section + var searchSpan = data.Slice((int)resourceFileOffset, (int)Math.Min(resourceSize, data.Length - resourceFileOffset)); + + // Look for "VS_VERSION_INFO" signature (wide string) + var vsVersionInfo = Encoding.Unicode.GetBytes("VS_VERSION_INFO"); + var vsInfoOffset = IndexOf(searchSpan, vsVersionInfo); + + if (vsInfoOffset < 0) + { + return; + } + + // Parse StringFileInfo to extract version strings + var versionInfoStart = (int)resourceFileOffset + vsInfoOffset; + ParseVersionStrings(data, versionInfoStart, searchSpan.Length - vsInfoOffset, + ref productVersion, ref fileVersion, ref companyName, ref productName, ref originalFilename); + } + + /// + /// Parse version strings from VS_VERSION_INFO structure. + /// + private static void ParseVersionStrings(ReadOnlySpan data, int offset, int maxLength, + ref string? productVersion, ref string? fileVersion, + ref string? companyName, ref string? productName, ref string? originalFilename) + { + // Search for common version string keys + var keys = new[] { "ProductVersion", "FileVersion", "CompanyName", "ProductName", "OriginalFilename" }; + + var searchSpan = data.Slice(offset, Math.Min(maxLength, data.Length - offset)); + + foreach (var key in keys) + { + var keyBytes = Encoding.Unicode.GetBytes(key); + var keyOffset = IndexOf(searchSpan, keyBytes); + + if (keyOffset < 0) + { + continue; + } + + // Value follows the key, aligned to 4-byte boundary + var valueStart = keyOffset + keyBytes.Length + 2; // +2 for null terminator + // Align to 4-byte boundary + valueStart = (valueStart + 3) & ~3; + + if (offset + valueStart >= data.Length) + { + continue; + } + + // Read null-terminated wide string value + var valueSpan = searchSpan[valueStart..]; + var nullTerm = -1; + for (var i = 0; i < valueSpan.Length - 1; i += 2) + { + if (valueSpan[i] == 0 && valueSpan[i + 1] == 0) + { + nullTerm = i; + break; + } + } + + if (nullTerm > 0) + { + var value = Encoding.Unicode.GetString(valueSpan[..nullTerm]); + if (!string.IsNullOrWhiteSpace(value)) + { + switch (key) + { + case "ProductVersion": + productVersion = value; + break; + case "FileVersion": + fileVersion = value; + break; + case "CompanyName": + companyName = value; + break; + case "ProductName": + productName = value; + break; + case "OriginalFilename": + originalFilename = value; + break; + } + } + } + } + } + + /// + /// Parse export directory for exported symbols. + /// + private static void ParseExportDirectory(ReadOnlySpan data, int dataDirectoryOffset, + List sections, List exports) + { + const int MAX_EXPORTS = 10000; + + var exportDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_EXPORT * 8; + if (exportDirOffset + 8 > data.Length) + { + return; + } + + var exportRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(exportDirOffset, 4)); + var exportSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(exportDirOffset + 4, 4)); + + if (exportRva == 0 || exportSize == 0) + { + return; + } + + if (!TryRvaToFileOffset(exportRva, sections, out var exportFileOffset)) + { + return; + } + + if (exportFileOffset + 40 > data.Length) + { + return; + } + + var exportSpan = data.Slice((int)exportFileOffset, 40); + + var numberOfNames = BinaryPrimitives.ReadUInt32LittleEndian(exportSpan.Slice(24, 4)); + var addressOfNames = BinaryPrimitives.ReadUInt32LittleEndian(exportSpan.Slice(32, 4)); + + if (numberOfNames == 0 || addressOfNames == 0) + { + return; + } + + if (!TryRvaToFileOffset(addressOfNames, sections, out var namesFileOffset)) + { + return; + } + + var count = Math.Min((int)numberOfNames, MAX_EXPORTS); + + for (var i = 0; i < count; i++) + { + var nameRvaOffset = (int)namesFileOffset + i * 4; + if (nameRvaOffset + 4 > data.Length) + { + break; + } + + var nameRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(nameRvaOffset, 4)); + if (!TryRvaToFileOffset(nameRva, sections, out var nameFileOffset)) + { + continue; + } + + if (nameFileOffset >= data.Length) + { + continue; + } + + var nameSpan = data[(int)nameFileOffset..]; + var nullTerm = nameSpan.IndexOf((byte)0); + var nameLength = nullTerm >= 0 ? nullTerm : Math.Min(256, nameSpan.Length); + + if (nameLength > 0) + { + var name = Encoding.ASCII.GetString(nameSpan[..nameLength]); + if (!string.IsNullOrWhiteSpace(name)) + { + exports.Add(name); + } + } + } + } + + /// + /// Simple byte sequence search. + /// + private static int IndexOf(ReadOnlySpan haystack, ReadOnlySpan needle) + { + for (var i = 0; i <= haystack.Length - needle.Length; i++) + { + if (haystack.Slice(i, needle.Length).SequenceEqual(needle)) + { + return i; + } + } + + return -1; + } + + /// + /// Map PE machine type to architecture string. + /// + private static string? MapPeMachine(ushort machine) + { + return machine switch + { + 0x014c => "x86", + 0x0200 => "ia64", + 0x8664 => "x86_64", + 0x01c0 => "arm", + 0x01c2 => "thumb", + 0x01c4 => "armnt", + 0xaa64 => "arm64", + 0x5032 => "riscv32", + 0x5064 => "riscv64", + 0x5128 => "riscv128", + _ => null + }; + } + + /// + /// Section header for RVA translation. + /// + private sealed record SectionHeader( + uint VirtualAddress, + uint VirtualSize, + uint RawDataPointer, + uint RawDataSize); +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/FindingEvidenceContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/FindingEvidenceContracts.cs new file mode 100644 index 00000000..f3dc4ce6 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/FindingEvidenceContracts.cs @@ -0,0 +1,451 @@ +// ----------------------------------------------------------------------------- +// FindingEvidenceContracts.cs +// Sprint: SPRINT_3800_0001_0001_evidence_api_models +// Description: Unified evidence API response contracts for findings. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.WebService.Contracts; + +/// +/// Unified evidence response for a finding, combining reachability, boundary, +/// VEX evidence, and score explanation. +/// +public sealed record FindingEvidenceResponse +{ + /// + /// Unique identifier for the finding. + /// + [JsonPropertyName("finding_id")] + public string FindingId { get; init; } = string.Empty; + + /// + /// CVE identifier (e.g., "CVE-2021-44228"). + /// + [JsonPropertyName("cve")] + public string Cve { get; init; } = string.Empty; + + /// + /// Component where the vulnerability was found. + /// + [JsonPropertyName("component")] + public ComponentRef? Component { get; init; } + + /// + /// Reachable call path from entrypoint to vulnerable sink. + /// Each element is a fully-qualified name (FQN). + /// + [JsonPropertyName("reachable_path")] + public IReadOnlyList? ReachablePath { get; init; } + + /// + /// Entrypoint proof (how the code is exposed). + /// + [JsonPropertyName("entrypoint")] + public EntrypointProof? Entrypoint { get; init; } + + /// + /// Boundary proof (surface exposure and controls). + /// + [JsonPropertyName("boundary")] + public BoundaryProofDto? Boundary { get; init; } + + /// + /// VEX (Vulnerability Exploitability eXchange) evidence. + /// + [JsonPropertyName("vex")] + public VexEvidenceDto? Vex { get; init; } + + /// + /// Score explanation with additive risk breakdown. + /// + [JsonPropertyName("score_explain")] + public ScoreExplanationDto? ScoreExplain { get; init; } + + /// + /// When the finding was last observed. + /// + [JsonPropertyName("last_seen")] + public DateTimeOffset LastSeen { get; init; } + + /// + /// When the evidence expires (for VEX/attestation freshness). + /// + [JsonPropertyName("expires_at")] + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// References to DSSE/in-toto attestations backing this evidence. + /// + [JsonPropertyName("attestation_refs")] + public IReadOnlyList? AttestationRefs { get; init; } +} + +/// +/// Reference to a component (package) by PURL and version. +/// +public sealed record ComponentRef +{ + /// + /// Package URL (PURL) identifier. + /// + [JsonPropertyName("purl")] + public string Purl { get; init; } = string.Empty; + + /// + /// Package name. + /// + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + /// + /// Package version. + /// + [JsonPropertyName("version")] + public string Version { get; init; } = string.Empty; + + /// + /// Package type/ecosystem (npm, maven, nuget, etc.). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; +} + +/// +/// Proof of how code is exposed as an entrypoint. +/// +public sealed record EntrypointProof +{ + /// + /// Type of entrypoint (http_handler, grpc_method, cli_command, etc.). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// Route or path (e.g., "/api/v1/users", "grpc.UserService.GetUser"). + /// + [JsonPropertyName("route")] + public string? Route { get; init; } + + /// + /// HTTP method if applicable (GET, POST, etc.). + /// + [JsonPropertyName("method")] + public string? Method { get; init; } + + /// + /// Authentication requirement (none, optional, required). + /// + [JsonPropertyName("auth")] + public string? Auth { get; init; } + + /// + /// Execution phase (startup, runtime, shutdown). + /// + [JsonPropertyName("phase")] + public string? Phase { get; init; } + + /// + /// Fully qualified name of the entrypoint symbol. + /// + [JsonPropertyName("fqn")] + public string Fqn { get; init; } = string.Empty; + + /// + /// Source file location. + /// + [JsonPropertyName("location")] + public SourceLocation? Location { get; init; } +} + +/// +/// Source file location reference. +/// +public sealed record SourceLocation +{ + /// + /// File path relative to repository root. + /// + [JsonPropertyName("file")] + public string File { get; init; } = string.Empty; + + /// + /// Line number (1-indexed). + /// + [JsonPropertyName("line")] + public int? Line { get; init; } + + /// + /// Column number (1-indexed). + /// + [JsonPropertyName("column")] + public int? Column { get; init; } +} + +/// +/// Boundary proof describing surface exposure and controls. +/// +public sealed record BoundaryProofDto +{ + /// + /// Kind of boundary (network, file, ipc, etc.). + /// + [JsonPropertyName("kind")] + public string Kind { get; init; } = string.Empty; + + /// + /// Surface descriptor (what is exposed). + /// + [JsonPropertyName("surface")] + public SurfaceDescriptor? Surface { get; init; } + + /// + /// Exposure descriptor (how it's exposed). + /// + [JsonPropertyName("exposure")] + public ExposureDescriptor? Exposure { get; init; } + + /// + /// Authentication descriptor. + /// + [JsonPropertyName("auth")] + public AuthDescriptor? Auth { get; init; } + + /// + /// Security controls in place. + /// + [JsonPropertyName("controls")] + public IReadOnlyList? Controls { get; init; } + + /// + /// When the boundary was last verified. + /// + [JsonPropertyName("last_seen")] + public DateTimeOffset LastSeen { get; init; } + + /// + /// Confidence score (0.0 to 1.0). + /// + [JsonPropertyName("confidence")] + public double Confidence { get; init; } +} + +/// +/// Describes what attack surface is exposed. +/// +public sealed record SurfaceDescriptor +{ + /// + /// Type of surface (api, web, cli, library). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// Protocol (http, https, grpc, tcp). + /// + [JsonPropertyName("protocol")] + public string? Protocol { get; init; } + + /// + /// Port number if network-exposed. + /// + [JsonPropertyName("port")] + public int? Port { get; init; } +} + +/// +/// Describes how the surface is exposed. +/// +public sealed record ExposureDescriptor +{ + /// + /// Exposure level (public, internal, private). + /// + [JsonPropertyName("level")] + public string Level { get; init; } = string.Empty; + + /// + /// Whether the exposure is internet-facing. + /// + [JsonPropertyName("internet_facing")] + public bool InternetFacing { get; init; } + + /// + /// Network zone (dmz, internal, trusted). + /// + [JsonPropertyName("zone")] + public string? Zone { get; init; } +} + +/// +/// Describes authentication requirements. +/// +public sealed record AuthDescriptor +{ + /// + /// Whether authentication is required. + /// + [JsonPropertyName("required")] + public bool Required { get; init; } + + /// + /// Authentication type (jwt, oauth2, basic, api_key). + /// + [JsonPropertyName("type")] + public string? Type { get; init; } + + /// + /// Required roles/scopes. + /// + [JsonPropertyName("roles")] + public IReadOnlyList? Roles { get; init; } +} + +/// +/// Describes a security control. +/// +public sealed record ControlDescriptor +{ + /// + /// Type of control (rate_limit, waf, input_validation, etc.). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// Whether the control is active. + /// + [JsonPropertyName("active")] + public bool Active { get; init; } + + /// + /// Control configuration details. + /// + [JsonPropertyName("config")] + public string? Config { get; init; } +} + +/// +/// VEX (Vulnerability Exploitability eXchange) evidence. +/// +public sealed record VexEvidenceDto +{ + /// + /// VEX status (not_affected, affected, fixed, under_investigation). + /// + [JsonPropertyName("status")] + public string Status { get; init; } = string.Empty; + + /// + /// Justification for the status. + /// + [JsonPropertyName("justification")] + public string? Justification { get; init; } + + /// + /// Impact statement explaining why not affected. + /// + [JsonPropertyName("impact")] + public string? Impact { get; init; } + + /// + /// Action statement (remediation steps). + /// + [JsonPropertyName("action")] + public string? Action { get; init; } + + /// + /// Reference to the VEX document/attestation. + /// + [JsonPropertyName("attestation_ref")] + public string? AttestationRef { get; init; } + + /// + /// When the VEX statement was issued. + /// + [JsonPropertyName("issued_at")] + public DateTimeOffset? IssuedAt { get; init; } + + /// + /// When the VEX statement expires. + /// + [JsonPropertyName("expires_at")] + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Source of the VEX statement (vendor, first-party, third-party). + /// + [JsonPropertyName("source")] + public string? Source { get; init; } +} + +/// +/// Score explanation with additive breakdown of risk factors. +/// +public sealed record ScoreExplanationDto +{ + /// + /// Kind of scoring algorithm (stellaops_risk_v1, cvss_v4, etc.). + /// + [JsonPropertyName("kind")] + public string Kind { get; init; } = string.Empty; + + /// + /// Final computed risk score. + /// + [JsonPropertyName("risk_score")] + public double RiskScore { get; init; } + + /// + /// Individual score contributions. + /// + [JsonPropertyName("contributions")] + public IReadOnlyList? Contributions { get; init; } + + /// + /// When the score was computed. + /// + [JsonPropertyName("last_seen")] + public DateTimeOffset LastSeen { get; init; } +} + +/// +/// Individual contribution to the risk score. +/// +public sealed record ScoreContributionDto +{ + /// + /// Factor name (cvss_base, epss, reachability, gate_multiplier, etc.). + /// + [JsonPropertyName("factor")] + public string Factor { get; init; } = string.Empty; + + /// + /// Weight applied to this factor (0.0 to 1.0). + /// + [JsonPropertyName("weight")] + public double Weight { get; init; } + + /// + /// Raw value before weighting. + /// + [JsonPropertyName("raw_value")] + public double RawValue { get; init; } + + /// + /// Weighted contribution to final score. + /// + [JsonPropertyName("contribution")] + public double Contribution { get; init; } + + /// + /// Human-readable explanation of this factor. + /// + [JsonPropertyName("explanation")] + public string? Explanation { get; init; } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/WitnessEndpoints.cs b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/WitnessEndpoints.cs new file mode 100644 index 00000000..9659679b --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/WitnessEndpoints.cs @@ -0,0 +1,251 @@ +// ----------------------------------------------------------------------------- +// WitnessEndpoints.cs +// Sprint: SPRINT_3700_0001_0001_witness_foundation +// Task: WIT-010 +// Description: API endpoints for DSSE-signed path witnesses. +// ----------------------------------------------------------------------------- + +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using StellaOps.Scanner.Storage.Repositories; +using StellaOps.Scanner.WebService.Security; + +namespace StellaOps.Scanner.WebService.Endpoints; + +internal static class WitnessEndpoints +{ + public static void MapWitnessEndpoints(this RouteGroupBuilder apiGroup, string witnessSegment = "witnesses") + { + ArgumentNullException.ThrowIfNull(apiGroup); + + var witnesses = apiGroup.MapGroup($"/{witnessSegment.TrimStart('/')}"); + + witnesses.MapGet("/{witnessId:guid}", HandleGetWitnessByIdAsync) + .WithName("scanner.witnesses.get") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.ScansRead); + + witnesses.MapGet("", HandleListWitnessesAsync) + .WithName("scanner.witnesses.list") + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(ScannerPolicies.ScansRead); + + witnesses.MapGet("/by-hash/{witnessHash}", HandleGetWitnessByHashAsync) + .WithName("scanner.witnesses.get-by-hash") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.ScansRead); + + witnesses.MapPost("/{witnessId:guid}/verify", HandleVerifyWitnessAsync) + .WithName("scanner.witnesses.verify") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.ScansRead); + } + + private static async Task HandleGetWitnessByIdAsync( + Guid witnessId, + IWitnessRepository repository, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + + var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false); + if (witness is null) + { + return Results.NotFound(); + } + + return Results.Ok(MapToDto(witness)); + } + + private static async Task HandleGetWitnessByHashAsync( + string witnessHash, + IWitnessRepository repository, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + + if (string.IsNullOrWhiteSpace(witnessHash)) + { + return Results.NotFound(); + } + + var witness = await repository.GetByHashAsync(witnessHash, cancellationToken).ConfigureAwait(false); + if (witness is null) + { + return Results.NotFound(); + } + + return Results.Ok(MapToDto(witness)); + } + + private static async Task HandleListWitnessesAsync( + HttpContext context, + IWitnessRepository repository, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + + var query = context.Request.Query; + IReadOnlyList witnesses; + + if (query.TryGetValue("scanId", out var scanIdValue) && Guid.TryParse(scanIdValue, out var scanId)) + { + witnesses = await repository.GetByScanIdAsync(scanId, cancellationToken).ConfigureAwait(false); + } + else if (query.TryGetValue("cve", out var cveValue) && !string.IsNullOrWhiteSpace(cveValue)) + { + witnesses = await repository.GetByCveAsync(cveValue!, cancellationToken).ConfigureAwait(false); + } + else if (query.TryGetValue("graphHash", out var graphHashValue) && !string.IsNullOrWhiteSpace(graphHashValue)) + { + witnesses = await repository.GetByGraphHashAsync(graphHashValue!, cancellationToken).ConfigureAwait(false); + } + else + { + // No filter provided - return empty list (avoid full table scan) + witnesses = []; + } + + return Results.Ok(new WitnessListResponseDto + { + Witnesses = witnesses.Select(MapToDto).ToList(), + TotalCount = witnesses.Count + }); + } + + private static async Task HandleVerifyWitnessAsync( + Guid witnessId, + IWitnessRepository repository, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + + var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false); + if (witness is null) + { + return Results.NotFound(); + } + + // Basic verification: check if DSSE envelope exists and witness hash is valid + var verificationStatus = "valid"; + string? verificationError = null; + + if (string.IsNullOrEmpty(witness.DsseEnvelope)) + { + verificationStatus = "unsigned"; + verificationError = "Witness does not have a DSSE envelope"; + } + else + { + // TODO: WIT-009 - Add actual DSSE signature verification via Attestor + // For now, just check the envelope structure + try + { + var envelope = JsonDocument.Parse(witness.DsseEnvelope); + if (!envelope.RootElement.TryGetProperty("signatures", out var signatures) || + signatures.GetArrayLength() == 0) + { + verificationStatus = "invalid"; + verificationError = "DSSE envelope has no signatures"; + } + } + catch (JsonException ex) + { + verificationStatus = "invalid"; + verificationError = $"Invalid DSSE envelope JSON: {ex.Message}"; + } + } + + // Record verification attempt + await repository.RecordVerificationAsync(new WitnessVerificationRecord + { + WitnessId = witnessId, + VerifiedAt = DateTimeOffset.UtcNow, + VerifiedBy = "api", + VerificationStatus = verificationStatus, + VerificationError = verificationError + }, cancellationToken).ConfigureAwait(false); + + return Results.Ok(new WitnessVerificationResponseDto + { + WitnessId = witnessId, + WitnessHash = witness.WitnessHash, + Status = verificationStatus, + Error = verificationError, + VerifiedAt = DateTimeOffset.UtcNow, + IsSigned = !string.IsNullOrEmpty(witness.DsseEnvelope) + }); + } + + private static WitnessResponseDto MapToDto(WitnessRecord record) + { + return new WitnessResponseDto + { + WitnessId = record.WitnessId, + WitnessHash = record.WitnessHash, + SchemaVersion = record.SchemaVersion, + WitnessType = record.WitnessType, + GraphHash = record.GraphHash, + ScanId = record.ScanId, + RunId = record.RunId, + CreatedAt = record.CreatedAt, + SignedAt = record.SignedAt, + SignerKeyId = record.SignerKeyId, + EntrypointFqn = record.EntrypointFqn, + SinkCve = record.SinkCve, + IsSigned = !string.IsNullOrEmpty(record.DsseEnvelope), + Payload = JsonDocument.Parse(record.PayloadJson).RootElement, + DsseEnvelope = string.IsNullOrEmpty(record.DsseEnvelope) + ? null + : JsonDocument.Parse(record.DsseEnvelope).RootElement + }; + } +} + +/// +/// Response DTO for a single witness. +/// +public sealed record WitnessResponseDto +{ + public Guid WitnessId { get; init; } + public required string WitnessHash { get; init; } + public required string SchemaVersion { get; init; } + public required string WitnessType { get; init; } + public required string GraphHash { get; init; } + public Guid? ScanId { get; init; } + public Guid? RunId { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? SignedAt { get; init; } + public string? SignerKeyId { get; init; } + public string? EntrypointFqn { get; init; } + public string? SinkCve { get; init; } + public bool IsSigned { get; init; } + public JsonElement Payload { get; init; } + public JsonElement? DsseEnvelope { get; init; } +} + +/// +/// Response DTO for witness list. +/// +public sealed record WitnessListResponseDto +{ + public required IReadOnlyList Witnesses { get; init; } + public int TotalCount { get; init; } +} + +/// +/// Response DTO for witness verification. +/// +public sealed record WitnessVerificationResponseDto +{ + public Guid WitnessId { get; init; } + public required string WitnessHash { get; init; } + public required string Status { get; init; } + public string? Error { get; init; } + public DateTimeOffset VerifiedAt { get; init; } + public bool IsSigned { get; init; } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index 5d93f911..d27f6d25 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -470,6 +470,7 @@ apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment); apiGroup.MapReachabilityDriftRootEndpoints(); apiGroup.MapProofSpineEndpoints(resolvedOptions.Api.SpinesSegment, resolvedOptions.Api.ScansSegment); apiGroup.MapReplayEndpoints(); +apiGroup.MapWitnessEndpoints(); // Sprint: SPRINT_3700_0001_0001 if (resolvedOptions.Features.EnablePolicyPreview) { diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/EpssIngestJob.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/EpssIngestJob.cs new file mode 100644 index 00000000..a56b06c1 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/EpssIngestJob.cs @@ -0,0 +1,272 @@ +// ----------------------------------------------------------------------------- +// EpssIngestJob.cs +// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage +// Task: EPSS-3410-009 +// Description: Background job that ingests EPSS data from online or bundle sources. +// ----------------------------------------------------------------------------- + +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Storage.Epss; +using StellaOps.Scanner.Storage.Repositories; + +namespace StellaOps.Scanner.Worker.Processing; + +/// +/// Options for the EPSS ingestion job. +/// +public sealed class EpssIngestOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Epss:Ingest"; + + /// + /// Whether the job is enabled. Default: true. + /// + public bool Enabled { get; set; } = true; + + /// + /// Cron schedule for EPSS ingestion. Default: "0 5 0 * * *" (00:05 UTC daily). + /// + public string Schedule { get; set; } = "0 5 0 * * *"; + + /// + /// Source type: "online" or "bundle". Default: "online". + /// + public string SourceType { get; set; } = "online"; + + /// + /// Bundle path for air-gapped ingestion (when SourceType is "bundle"). + /// + public string? BundlePath { get; set; } + + /// + /// Initial delay before first run. Default: 30 seconds. + /// + public TimeSpan InitialDelay { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Retry delay on failure. Default: 5 minutes. + /// + public TimeSpan RetryDelay { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Maximum retry attempts. Default: 3. + /// + public int MaxRetries { get; set; } = 3; +} + +/// +/// Background service that ingests EPSS data on a schedule. +/// Supports online (FIRST.org) and offline (bundle) sources. +/// +public sealed class EpssIngestJob : BackgroundService +{ + private readonly IEpssRepository _repository; + private readonly EpssOnlineSource _onlineSource; + private readonly EpssBundleSource _bundleSource; + private readonly EpssCsvStreamParser _parser; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly ActivitySource _activitySource = new("StellaOps.Scanner.EpssIngest"); + + public EpssIngestJob( + IEpssRepository repository, + EpssOnlineSource onlineSource, + EpssBundleSource bundleSource, + EpssCsvStreamParser parser, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _onlineSource = onlineSource ?? throw new ArgumentNullException(nameof(onlineSource)); + _bundleSource = bundleSource ?? throw new ArgumentNullException(nameof(bundleSource)); + _parser = parser ?? throw new ArgumentNullException(nameof(parser)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("EPSS ingest job started"); + + var opts = _options.Value; + + if (!opts.Enabled) + { + _logger.LogInformation("EPSS ingest job is disabled"); + return; + } + + // Initial delay to let the system stabilize + await Task.Delay(opts.InitialDelay, stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + var now = _timeProvider.GetUtcNow(); + var nextRun = ComputeNextRun(now, opts.Schedule); + var delay = nextRun - now; + + if (delay > TimeSpan.Zero) + { + _logger.LogDebug("EPSS ingest job waiting until {NextRun}", nextRun); + await Task.Delay(delay, stoppingToken); + } + + if (stoppingToken.IsCancellationRequested) + { + break; + } + + await RunIngestionWithRetryAsync(stoppingToken); + } + + _logger.LogInformation("EPSS ingest job stopped"); + } + + /// + /// Runs ingestion for a specific date. Used by tests and manual triggers. + /// + public async Task IngestAsync(DateOnly modelDate, CancellationToken cancellationToken = default) + { + using var activity = _activitySource.StartActivity("epss.ingest", ActivityKind.Internal); + activity?.SetTag("epss.model_date", modelDate.ToString("yyyy-MM-dd")); + + var opts = _options.Value; + var stopwatch = Stopwatch.StartNew(); + + _logger.LogInformation("Starting EPSS ingestion for {ModelDate}", modelDate); + + try + { + // Get source based on configuration + IEpssSource source = opts.SourceType.Equals("bundle", StringComparison.OrdinalIgnoreCase) + ? _bundleSource + : _onlineSource; + + // Retrieve the EPSS file + var sourceFile = await source.GetAsync(modelDate, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Retrieved EPSS file from {SourceUri}, size={Size}", + sourceFile.SourceUri, + sourceFile.Content.Length); + + // Begin import run + var importRun = await _repository.BeginImportAsync( + modelDate, + sourceFile.SourceUri, + _timeProvider.GetUtcNow(), + sourceFile.FileSha256, + cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("Created import run {ImportRunId}", importRun.ImportRunId); + + try + { + // Parse and write snapshot + await using var stream = new MemoryStream(sourceFile.Content); + var session = _parser.ParseGzip(stream); + + var writeResult = await _repository.WriteSnapshotAsync( + importRun.ImportRunId, + modelDate, + _timeProvider.GetUtcNow(), + session, + cancellationToken).ConfigureAwait(false); + + // Mark success + await _repository.MarkImportSucceededAsync( + importRun.ImportRunId, + session.RowCount, + session.DecompressedSha256, + session.ModelVersionTag, + session.PublishedDate, + cancellationToken).ConfigureAwait(false); + + stopwatch.Stop(); + + _logger.LogInformation( + "EPSS ingestion completed: modelDate={ModelDate}, rows={RowCount}, cves={CveCount}, duration={Duration}ms", + modelDate, + writeResult.RowCount, + writeResult.DistinctCveCount, + stopwatch.ElapsedMilliseconds); + + activity?.SetTag("epss.row_count", writeResult.RowCount); + activity?.SetTag("epss.cve_count", writeResult.DistinctCveCount); + activity?.SetTag("epss.duration_ms", stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + await _repository.MarkImportFailedAsync( + importRun.ImportRunId, + ex.Message, + cancellationToken).ConfigureAwait(false); + + throw; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "EPSS ingestion failed for {ModelDate}", modelDate); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } + + private async Task RunIngestionWithRetryAsync(CancellationToken cancellationToken) + { + var opts = _options.Value; + var modelDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime); + + for (var attempt = 1; attempt <= opts.MaxRetries; attempt++) + { + try + { + await IngestAsync(modelDate, cancellationToken); + return; + } + catch (Exception ex) when (attempt < opts.MaxRetries) + { + _logger.LogWarning( + ex, + "EPSS ingestion attempt {Attempt}/{MaxRetries} failed, retrying in {RetryDelay}", + attempt, + opts.MaxRetries, + opts.RetryDelay); + + await Task.Delay(opts.RetryDelay, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "EPSS ingestion failed after {MaxRetries} attempts", + opts.MaxRetries); + } + } + } + + private static DateTimeOffset ComputeNextRun(DateTimeOffset now, string cronSchedule) + { + // Simple cron parser for "0 5 0 * * *" (seconds minutes hours day month dayOfWeek) + // For MVP, we just schedule for 00:05 UTC the next day + var today = now.UtcDateTime.Date; + var scheduledTime = today.AddMinutes(5); + + if (now.UtcDateTime > scheduledTime) + { + scheduledTime = scheduledTime.AddDays(1); + } + + return new DateTimeOffset(scheduledTime, TimeSpan.Zero); + } +} diff --git a/src/Scanner/StellaOps.Scanner.Worker/Program.cs b/src/Scanner/StellaOps.Scanner.Worker/Program.cs index afd6d1e4..b2d5057c 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Program.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Program.cs @@ -113,6 +113,12 @@ if (!string.IsNullOrWhiteSpace(connectionString)) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + + // EPSS ingestion job (Sprint: SPRINT_3410_0001_0001) + builder.Services.AddOptions() + .BindConfiguration(EpssIngestOptions.SectionName) + .ValidateOnStart(); + builder.Services.AddHostedService(); } else { diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/INativeComponentEmitter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/INativeComponentEmitter.cs new file mode 100644 index 00000000..767a6ebc --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/INativeComponentEmitter.cs @@ -0,0 +1,44 @@ +using StellaOps.Scanner.Analyzers.Native.Index; + +namespace StellaOps.Scanner.Emit.Native; + +/// +/// Result of emitting a native component. +/// +/// Package URL for the component. +/// Component name (usually the filename). +/// Component version if known. +/// Original binary metadata. +/// Whether this was matched from the Build-ID index. +/// The index lookup result if matched. +public sealed record NativeComponentEmitResult( + string Purl, + string Name, + string? Version, + NativeBinaryMetadata Metadata, + bool IndexMatch, + BuildIdLookupResult? LookupResult); + +/// +/// Interface for emitting native binary components for SBOM generation. +/// +public interface INativeComponentEmitter +{ + /// + /// Emits a native component from binary metadata. + /// + /// Binary metadata. + /// Cancellation token. + /// Component emission result. + Task EmitAsync(NativeBinaryMetadata metadata, CancellationToken cancellationToken = default); + + /// + /// Emits multiple native components. + /// + /// List of binary metadata. + /// Cancellation token. + /// Component emission results. + Task> EmitBatchAsync( + IEnumerable metadataList, + CancellationToken cancellationToken = default); +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeBinaryMetadata.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeBinaryMetadata.cs new file mode 100644 index 00000000..99af6ddc --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeBinaryMetadata.cs @@ -0,0 +1,55 @@ +namespace StellaOps.Scanner.Emit.Native; + +/// +/// Metadata for a native binary component. +/// +public sealed record NativeBinaryMetadata +{ + /// Binary format (elf, pe, macho) + public required string Format { get; init; } + + /// Build-ID with prefix (gnu-build-id:..., pe-cv:..., macho-uuid:...) + public string? BuildId { get; init; } + + /// CPU architecture (x86_64, aarch64, arm, i686, etc.) + public string? Architecture { get; init; } + + /// Whether this is a 64-bit binary + public bool Is64Bit { get; init; } + + /// Operating system or platform + public string? Platform { get; init; } + + /// File path within the container layer + public required string FilePath { get; init; } + + /// SHA-256 digest of the file + public string? FileDigest { get; init; } + + /// File size in bytes + public long FileSize { get; init; } + + /// Container layer digest where this binary was introduced + public string? LayerDigest { get; init; } + + /// Layer index (0-based) + public int LayerIndex { get; init; } + + /// Product version from PE version resource + public string? ProductVersion { get; init; } + + /// File version from PE version resource + public string? FileVersion { get; init; } + + /// Company name from PE version resource + public string? CompanyName { get; init; } + + /// Hardening flags (PIE, RELRO, NX, etc.) + public IReadOnlyDictionary? HardeningFlags { get; init; } + + /// Whether the binary is signed + public bool IsSigned { get; init; } + + /// Signature details (Authenticode, codesign, etc.) + public string? SignatureDetails { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeComponentEmitter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeComponentEmitter.cs new file mode 100644 index 00000000..491f003b --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativeComponentEmitter.cs @@ -0,0 +1,155 @@ +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.Native.Index; + +namespace StellaOps.Scanner.Emit.Native; + +/// +/// Emits native binary components for SBOM generation. +/// Uses the Build-ID index to resolve PURLs when possible. +/// +public sealed class NativeComponentEmitter : INativeComponentEmitter +{ + private readonly IBuildIdIndex _buildIdIndex; + private readonly NativePurlBuilder _purlBuilder; + private readonly ILogger _logger; + + /// + /// Creates a new native component emitter. + /// + public NativeComponentEmitter( + IBuildIdIndex buildIdIndex, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(buildIdIndex); + ArgumentNullException.ThrowIfNull(logger); + + _buildIdIndex = buildIdIndex; + _purlBuilder = new NativePurlBuilder(); + _logger = logger; + } + + /// + public async Task EmitAsync( + NativeBinaryMetadata metadata, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(metadata); + + // Try to resolve via Build-ID index + BuildIdLookupResult? lookupResult = null; + + if (!string.IsNullOrWhiteSpace(metadata.BuildId)) + { + lookupResult = await _buildIdIndex.LookupAsync(metadata.BuildId, cancellationToken).ConfigureAwait(false); + } + + string purl; + string? version = null; + bool indexMatch = false; + + if (lookupResult is not null) + { + // Index match - use the resolved PURL + purl = _purlBuilder.FromIndexResult(lookupResult); + version = lookupResult.Version; + indexMatch = true; + + _logger.LogDebug( + "Resolved binary {FilePath} via Build-ID index: {Purl}", + metadata.FilePath, + purl); + } + else + { + // No match - generate generic PURL + purl = _purlBuilder.FromUnresolvedBinary(metadata); + version = metadata.ProductVersion ?? metadata.FileVersion; + + _logger.LogDebug( + "Unresolved binary {FilePath}, generated generic PURL: {Purl}", + metadata.FilePath, + purl); + } + + var name = Path.GetFileName(metadata.FilePath); + + return new NativeComponentEmitResult( + Purl: purl, + Name: name, + Version: version, + Metadata: metadata, + IndexMatch: indexMatch, + LookupResult: lookupResult); + } + + /// + public async Task> EmitBatchAsync( + IEnumerable metadataList, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(metadataList); + + var metadataArray = metadataList.ToArray(); + if (metadataArray.Length == 0) + { + return Array.Empty(); + } + + // Batch lookup for all Build-IDs + var buildIds = metadataArray + .Where(m => !string.IsNullOrWhiteSpace(m.BuildId)) + .Select(m => m.BuildId!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var lookupResults = await _buildIdIndex.BatchLookupAsync(buildIds, cancellationToken).ConfigureAwait(false); + var lookupMap = lookupResults.ToDictionary( + r => r.BuildId, + StringComparer.OrdinalIgnoreCase); + + _logger.LogDebug( + "Batch lookup: {Total} binaries, {Resolved} resolved via index", + metadataArray.Length, + lookupMap.Count); + + // Emit components + var results = new List(metadataArray.Length); + + foreach (var metadata in metadataArray) + { + BuildIdLookupResult? lookupResult = null; + + if (!string.IsNullOrWhiteSpace(metadata.BuildId) && + lookupMap.TryGetValue(metadata.BuildId, out var result)) + { + lookupResult = result; + } + + string purl; + string? version = null; + bool indexMatch = false; + + if (lookupResult is not null) + { + purl = _purlBuilder.FromIndexResult(lookupResult); + version = lookupResult.Version; + indexMatch = true; + } + else + { + purl = _purlBuilder.FromUnresolvedBinary(metadata); + version = metadata.ProductVersion ?? metadata.FileVersion; + } + + results.Add(new NativeComponentEmitResult( + Purl: purl, + Name: Path.GetFileName(metadata.FilePath), + Version: version, + Metadata: metadata, + IndexMatch: indexMatch, + LookupResult: lookupResult)); + } + + return results; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativePurlBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativePurlBuilder.cs new file mode 100644 index 00000000..0a9dac72 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Native/NativePurlBuilder.cs @@ -0,0 +1,115 @@ +using StellaOps.Scanner.Analyzers.Native.Index; + +namespace StellaOps.Scanner.Emit.Native; + +/// +/// Builds PURLs for native binaries. +/// +public sealed class NativePurlBuilder +{ + /// + /// Builds a PURL from a Build-ID index lookup result. + /// + /// The index lookup result. + /// PURL string. + public string FromIndexResult(BuildIdLookupResult lookupResult) + { + ArgumentNullException.ThrowIfNull(lookupResult); + return lookupResult.Purl; + } + + /// + /// Builds a PURL for an unresolved native binary. + /// Falls back to pkg:generic with build-id qualifier. + /// + /// Binary metadata. + /// PURL string. + public string FromUnresolvedBinary(NativeBinaryMetadata metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + + // Extract filename from path + var fileName = Path.GetFileName(metadata.FilePath); + + // Build pkg:generic PURL with build-id qualifier + var purl = $"pkg:generic/{EncodeComponent(fileName)}@unknown"; + + var qualifiers = new List(); + + if (!string.IsNullOrWhiteSpace(metadata.BuildId)) + { + qualifiers.Add($"build-id={EncodeComponent(metadata.BuildId)}"); + } + + if (!string.IsNullOrWhiteSpace(metadata.Architecture)) + { + qualifiers.Add($"arch={EncodeComponent(metadata.Architecture)}"); + } + + if (!string.IsNullOrWhiteSpace(metadata.Platform)) + { + qualifiers.Add($"os={EncodeComponent(metadata.Platform)}"); + } + + if (!string.IsNullOrWhiteSpace(metadata.FileDigest)) + { + qualifiers.Add($"checksum={EncodeComponent(metadata.FileDigest)}"); + } + + if (qualifiers.Count > 0) + { + purl += "?" + string.Join("&", qualifiers.OrderBy(q => q, StringComparer.Ordinal)); + } + + return purl; + } + + /// + /// Builds a PURL for a binary with known distro information. + /// + /// Distribution type (deb, rpm, apk, etc.) + /// Distribution name (debian, fedora, alpine, etc.) + /// Package name. + /// Package version. + /// CPU architecture. + /// PURL string. + public string FromDistroPackage( + string distro, + string distroName, + string packageName, + string version, + string? architecture = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(distro); + ArgumentException.ThrowIfNullOrWhiteSpace(distroName); + ArgumentException.ThrowIfNullOrWhiteSpace(packageName); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + + // Map distro type to PURL type + var purlType = distro.ToLowerInvariant() switch + { + "deb" or "debian" or "ubuntu" => "deb", + "rpm" or "fedora" or "rhel" or "centos" => "rpm", + "apk" or "alpine" => "apk", + "pacman" or "arch" => "pacman", + _ => "generic" + }; + + var purl = $"pkg:{purlType}/{EncodeComponent(distroName)}/{EncodeComponent(packageName)}@{EncodeComponent(version)}"; + + if (!string.IsNullOrWhiteSpace(architecture)) + { + purl += $"?arch={EncodeComponent(architecture)}"; + } + + return purl; + } + + private static string EncodeComponent(string value) + { + // PURL percent-encoding: only encode special characters + return Uri.EscapeDataString(value) + .Replace("%2F", "/", StringComparison.Ordinal) // Allow / in names + .Replace("%40", "@", StringComparison.Ordinal); // @ is already version separator + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj index 50a1c4aa..faed28e3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/IReachabilityWitnessPublisher.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/IReachabilityWitnessPublisher.cs new file mode 100644 index 00000000..5bb40236 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/IReachabilityWitnessPublisher.cs @@ -0,0 +1,44 @@ +namespace StellaOps.Scanner.Reachability.Attestation; + +/// +/// Result of publishing a reachability witness. +/// +/// Hash of the in-toto statement. +/// Hash of the rich graph. +/// CAS URI where graph is stored (if applicable). +/// Rekor transparency log index (if published). +/// Rekor log ID (if published). +/// Serialized DSSE envelope. +public sealed record ReachabilityWitnessPublishResult( + string StatementHash, + string GraphHash, + string? CasUri, + long? RekorLogIndex, + string? RekorLogId, + byte[] DsseEnvelopeBytes); + +/// +/// Interface for publishing reachability witness attestations. +/// +public interface IReachabilityWitnessPublisher +{ + /// + /// Publishes a reachability witness attestation for the given graph. + /// + /// The rich graph to attest. + /// Canonical JSON bytes of the graph. + /// Hash of the graph bytes. + /// Subject artifact digest. + /// Optional policy hash. + /// Optional source commit. + /// Cancellation token. + /// Publication result with CAS URI and optional Rekor proof. + Task PublishAsync( + RichGraph graph, + byte[] graphBytes, + string graphHash, + string subjectDigest, + string? policyHash = null, + string? sourceCommit = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessDsseBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessDsseBuilder.cs new file mode 100644 index 00000000..402a0eb7 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessDsseBuilder.cs @@ -0,0 +1,207 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using StellaOps.Cryptography; + +namespace StellaOps.Scanner.Reachability.Attestation; + +/// +/// Builds DSSE envelopes for reachability witness attestations. +/// Follows in-toto attestation framework with stellaops.reachabilityWitness predicate. +/// +public sealed class ReachabilityWitnessDsseBuilder +{ + private readonly ICryptoHash _cryptoHash; + private readonly TimeProvider _timeProvider; + + private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + /// + /// Creates a new DSSE builder. + /// + /// Crypto hash service for content addressing. + /// Time provider for timestamps. + public ReachabilityWitnessDsseBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null) + { + _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// Builds an in-toto statement from a RichGraph. + /// + /// The rich graph to attest. + /// The computed hash of the canonical graph JSON. + /// The subject artifact digest (e.g., image digest). + /// Optional CAS URI where graph is stored. + /// Optional policy hash that was applied. + /// Optional source commit. + /// An in-toto statement ready for DSSE signing. + public InTotoStatement BuildStatement( + RichGraph graph, + string graphHash, + string subjectDigest, + string? graphCasUri = null, + string? policyHash = null, + string? sourceCommit = null) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrWhiteSpace(graphHash); + ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest); + + var generatedAt = _timeProvider.GetUtcNow(); + + var predicate = new ReachabilityWitnessStatement + { + GraphHash = graphHash, + GraphCasUri = graphCasUri, + GeneratedAt = generatedAt, + Language = graph.Nodes.FirstOrDefault()?.Lang ?? "unknown", + NodeCount = graph.Nodes.Count, + EdgeCount = graph.Edges.Count, + EntrypointCount = graph.Roots?.Count ?? 0, + SinkCount = CountSinks(graph), + ReachableSinkCount = CountReachableSinks(graph), + PolicyHash = policyHash, + AnalyzerVersion = graph.Analyzer.Version ?? "unknown", + SourceCommit = sourceCommit, + SubjectDigest = subjectDigest + }; + + return new InTotoStatement + { + Type = "https://in-toto.io/Statement/v1", + Subject = new[] + { + new InTotoSubject + { + Name = ExtractSubjectName(subjectDigest), + Digest = new Dictionary + { + [ExtractDigestAlgorithm(subjectDigest)] = ExtractDigestValue(subjectDigest) + } + } + }, + PredicateType = "https://stella.ops/reachabilityWitness/v1", + Predicate = predicate + }; + } + + /// + /// Serializes an in-toto statement to canonical JSON. + /// + public byte[] SerializeStatement(InTotoStatement statement) + { + ArgumentNullException.ThrowIfNull(statement); + return JsonSerializer.SerializeToUtf8Bytes(statement, CanonicalJsonOptions); + } + + /// + /// Computes the hash of a serialized statement. + /// + public string ComputeStatementHash(byte[] statementBytes) + { + ArgumentNullException.ThrowIfNull(statementBytes); + return _cryptoHash.ComputePrefixedHashForPurpose(statementBytes, HashPurpose.Graph); + } + + private static int CountSinks(RichGraph graph) + { + // Count nodes with sink-related kinds (sql, crypto, deserialize, etc.) + return graph.Nodes.Count(n => IsSinkKind(n.Kind)); + } + + private static int CountReachableSinks(RichGraph graph) + { + // A sink is reachable if it has incoming edges + var nodesWithIncoming = new HashSet(StringComparer.Ordinal); + foreach (var edge in graph.Edges) + { + if (!string.IsNullOrEmpty(edge.To)) + { + nodesWithIncoming.Add(edge.To); + } + } + + return graph.Nodes.Count(n => + IsSinkKind(n.Kind) && + nodesWithIncoming.Contains(n.Id)); + } + + private static bool IsSinkKind(string? kind) + { + // Recognize common sink kinds from the taxonomy + return kind?.ToLowerInvariant() switch + { + "sink" => true, + "sql" => true, + "crypto" => true, + "deserialize" => true, + "file" => true, + "network" => true, + "command" => true, + "reflection" => true, + _ => false + }; + } + + private static string ExtractSubjectName(string subjectDigest) + { + // For image digests like "sha256:abc123", return the full string + // For other formats, try to extract a meaningful name + return subjectDigest; + } + + private static string ExtractDigestAlgorithm(string subjectDigest) + { + var colonIndex = subjectDigest.IndexOf(':'); + return colonIndex > 0 ? subjectDigest[..colonIndex] : "sha256"; + } + + private static string ExtractDigestValue(string subjectDigest) + { + var colonIndex = subjectDigest.IndexOf(':'); + return colonIndex > 0 ? subjectDigest[(colonIndex + 1)..] : subjectDigest; + } +} + +/// +/// In-toto Statement structure per https://github.com/in-toto/attestation. +/// +public sealed record InTotoStatement +{ + /// Statement type (always "https://in-toto.io/Statement/v1") + [JsonPropertyName("_type")] + public required string Type { get; init; } + + /// Array of subjects this attestation refers to + [JsonPropertyName("subject")] + public required InTotoSubject[] Subject { get; init; } + + /// URI identifying the predicate type + [JsonPropertyName("predicateType")] + public required string PredicateType { get; init; } + + /// The predicate object (type varies by predicateType) + [JsonPropertyName("predicate")] + public required object Predicate { get; init; } +} + +/// +/// In-toto Subject structure. +/// +public sealed record InTotoSubject +{ + /// Subject name (e.g., artifact path or identifier) + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// Map of digest algorithm to digest value + [JsonPropertyName("digest")] + public required Dictionary Digest { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessOptions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessOptions.cs new file mode 100644 index 00000000..bd1176c9 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessOptions.cs @@ -0,0 +1,45 @@ +namespace StellaOps.Scanner.Reachability.Attestation; + +/// +/// Configuration for reachability witness attestation. +/// +public sealed class ReachabilityWitnessOptions +{ + public const string SectionName = "Scanner:ReachabilityWitness"; + + /// Whether to generate DSSE attestations + public bool Enabled { get; set; } = true; + + /// Attestation tier (standard, regulated, air-gapped, dev) + public AttestationTier Tier { get; set; } = AttestationTier.Standard; + + /// Whether to publish to Rekor transparency log + public bool PublishToRekor { get; set; } = true; + + /// Whether to store graph in CAS + public bool StoreInCas { get; set; } = true; + + /// Maximum number of edge bundles to attest (for tier=standard) + public int MaxEdgeBundles { get; set; } = 5; + + /// Key ID for signing (uses default if not specified) + public string? SigningKeyId { get; set; } +} + +/// +/// Attestation tiers per hybrid-attestation.md. +/// +public enum AttestationTier +{ + /// Standard: Graph DSSE + Rekor, optional edge bundles + Standard, + + /// Regulated: Full attestation with strict signing + Regulated, + + /// Air-gapped: Local-only, no Rekor + AirGapped, + + /// Development: Minimal attestation for testing + Dev +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessPublisher.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessPublisher.cs new file mode 100644 index 00000000..a81a0577 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessPublisher.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Cryptography; + +namespace StellaOps.Scanner.Reachability.Attestation; + +/// +/// Publishes reachability witness attestations to CAS and Rekor. +/// +public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher +{ + private readonly ReachabilityWitnessOptions _options; + private readonly ReachabilityWitnessDsseBuilder _dsseBuilder; + private readonly ICryptoHash _cryptoHash; + private readonly ILogger _logger; + + /// + /// Creates a new reachability witness publisher. + /// + public ReachabilityWitnessPublisher( + IOptions options, + ICryptoHash cryptoHash, + ILogger logger, + TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(cryptoHash); + ArgumentNullException.ThrowIfNull(logger); + + _options = options.Value; + _cryptoHash = cryptoHash; + _logger = logger; + _dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider); + } + + /// + public async Task PublishAsync( + RichGraph graph, + byte[] graphBytes, + string graphHash, + string subjectDigest, + string? policyHash = null, + string? sourceCommit = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(graphBytes); + ArgumentException.ThrowIfNullOrWhiteSpace(graphHash); + ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest); + + if (!_options.Enabled) + { + _logger.LogDebug("Reachability witness attestation is disabled"); + return new ReachabilityWitnessPublishResult( + StatementHash: string.Empty, + GraphHash: graphHash, + CasUri: null, + RekorLogIndex: null, + RekorLogId: null, + DsseEnvelopeBytes: Array.Empty()); + } + + string? casUri = null; + + // Step 1: Store graph in CAS (if enabled) + if (_options.StoreInCas) + { + casUri = await StoreInCasAsync(graphBytes, graphHash, cancellationToken).ConfigureAwait(false); + } + + // Step 2: Build in-toto statement + var statement = _dsseBuilder.BuildStatement( + graph, + graphHash, + subjectDigest, + casUri, + policyHash, + sourceCommit); + + var statementBytes = _dsseBuilder.SerializeStatement(statement); + var statementHash = _dsseBuilder.ComputeStatementHash(statementBytes); + + _logger.LogInformation( + "Built reachability witness statement: hash={StatementHash}, nodes={NodeCount}, edges={EdgeCount}", + statementHash, + graph.Nodes.Count, + graph.Edges.Count); + + // Step 3: Create DSSE envelope (placeholder - actual signing via Attestor service) + var dsseEnvelope = CreateDsseEnvelope(statementBytes); + + // Step 4: Submit to Rekor (if enabled and not air-gapped) + long? rekorLogIndex = null; + string? rekorLogId = null; + + if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped) + { + (rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(dsseEnvelope, cancellationToken).ConfigureAwait(false); + } + else if (_options.Tier == AttestationTier.AirGapped) + { + _logger.LogDebug("Skipping Rekor submission (air-gapped tier)"); + } + + return new ReachabilityWitnessPublishResult( + StatementHash: statementHash, + GraphHash: graphHash, + CasUri: casUri, + RekorLogIndex: rekorLogIndex, + RekorLogId: rekorLogId, + DsseEnvelopeBytes: dsseEnvelope); + } + + private Task StoreInCasAsync(byte[] graphBytes, string graphHash, CancellationToken cancellationToken) + { + // TODO: Integrate with actual CAS storage (BID-007) + // For now, return a placeholder CAS URI based on hash + var casUri = $"cas://local/{graphHash}"; + _logger.LogDebug("Stored graph in CAS: {CasUri}", casUri); + return Task.FromResult(casUri); + } + + private byte[] CreateDsseEnvelope(byte[] statementBytes) + { + // TODO: Integrate with Attestor DSSE signing service (RWD-008) + // For now, return unsigned envelope structure + // In production, this would call the Attestor service to sign the statement + + // Minimal DSSE envelope structure (unsigned) + var envelope = new + { + payloadType = "application/vnd.in-toto+json", + payload = Convert.ToBase64String(statementBytes), + signatures = Array.Empty() // Will be populated by Attestor + }; + + return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope); + } + + private Task<(long? logIndex, string? logId)> SubmitToRekorAsync(byte[] dsseEnvelope, CancellationToken cancellationToken) + { + // TODO: Integrate with Rekor backend (RWD-008) + // For now, return placeholder values + _logger.LogDebug("Rekor submission placeholder - actual integration pending"); + return Task.FromResult<(long?, string?)>((null, null)); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessStatement.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessStatement.cs new file mode 100644 index 00000000..416a0245 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Attestation/ReachabilityWitnessStatement.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.Reachability.Attestation; + +/// +/// Reachability witness statement for DSSE predicate. +/// Conforms to stella.ops/reachabilityWitness@v1 schema. +/// +public sealed record ReachabilityWitnessStatement +{ + /// Schema identifier + [JsonPropertyName("schema")] + public string Schema { get; init; } = "stella.ops/reachabilityWitness@v1"; + + /// BLAKE3 hash of the canonical RichGraph JSON + [JsonPropertyName("graphHash")] + public required string GraphHash { get; init; } + + /// CAS URI where graph is stored + [JsonPropertyName("graphCasUri")] + public string? GraphCasUri { get; init; } + + /// When the analysis was performed (ISO-8601) + [JsonPropertyName("generatedAt")] + public required DateTimeOffset GeneratedAt { get; init; } + + /// Primary language of the analyzed code + [JsonPropertyName("language")] + public required string Language { get; init; } + + /// Number of nodes in the graph + [JsonPropertyName("nodeCount")] + public required int NodeCount { get; init; } + + /// Number of edges in the graph + [JsonPropertyName("edgeCount")] + public required int EdgeCount { get; init; } + + /// Number of entrypoints identified + [JsonPropertyName("entrypointCount")] + public required int EntrypointCount { get; init; } + + /// Total number of sinks in taxonomy + [JsonPropertyName("sinkCount")] + public required int SinkCount { get; init; } + + /// Number of reachable sinks + [JsonPropertyName("reachableSinkCount")] + public required int ReachableSinkCount { get; init; } + + /// Policy hash that was applied (if any) + [JsonPropertyName("policyHash")] + public string? PolicyHash { get; init; } + + /// Analyzer version used + [JsonPropertyName("analyzerVersion")] + public required string AnalyzerVersion { get; init; } + + /// Git commit of the analyzed code + [JsonPropertyName("sourceCommit")] + public string? SourceCommit { get; init; } + + /// Subject artifact (image digest or file hash) + [JsonPropertyName("subjectDigest")] + public required string SubjectDigest { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/IPathWitnessBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/IPathWitnessBuilder.cs new file mode 100644 index 00000000..f0a0fd07 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/IPathWitnessBuilder.cs @@ -0,0 +1,175 @@ +namespace StellaOps.Scanner.Reachability.Witnesses; + +/// +/// Builds path witnesses from reachability analysis results. +/// +public interface IPathWitnessBuilder +{ + /// + /// Creates a path witness for a reachable vulnerability. + /// + /// The witness creation request containing all necessary context. + /// Cancellation token. + /// A signed path witness or null if the path is not reachable. + Task BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default); + + /// + /// Creates multiple path witnesses for all reachable paths to a vulnerability. + /// + /// The batch witness request. + /// Cancellation token. + /// All generated witnesses. + IAsyncEnumerable BuildAllAsync(BatchWitnessRequest request, CancellationToken cancellationToken = default); +} + +/// +/// Request to build a single path witness. +/// +public sealed record PathWitnessRequest +{ + /// + /// The SBOM digest for artifact context. + /// + public required string SbomDigest { get; init; } + + /// + /// Package URL of the vulnerable component. + /// + public required string ComponentPurl { get; init; } + + /// + /// Vulnerability ID (e.g., "CVE-2024-12345"). + /// + public required string VulnId { get; init; } + + /// + /// Vulnerability source (e.g., "NVD"). + /// + public required string VulnSource { get; init; } + + /// + /// Affected version range. + /// + public required string AffectedRange { get; init; } + + /// + /// Entrypoint symbol ID. + /// + public required string EntrypointSymbolId { get; init; } + + /// + /// Entrypoint kind (http, grpc, cli, etc.). + /// + public required string EntrypointKind { get; init; } + + /// + /// Human-readable entrypoint name. + /// + public required string EntrypointName { get; init; } + + /// + /// Sink symbol ID. + /// + public required string SinkSymbolId { get; init; } + + /// + /// Sink taxonomy type. + /// + public required string SinkType { get; init; } + + /// + /// The call graph to use for path finding. + /// + public required RichGraph CallGraph { get; init; } + + /// + /// BLAKE3 digest of the call graph. + /// + public required string CallgraphDigest { get; init; } + + /// + /// Optional attack surface digest. + /// + public string? SurfaceDigest { get; init; } + + /// + /// Optional analysis config digest. + /// + public string? AnalysisConfigDigest { get; init; } + + /// + /// Optional build ID. + /// + public string? BuildId { get; init; } +} + +/// +/// Request to build witnesses for all paths to a vulnerability. +/// +public sealed record BatchWitnessRequest +{ + /// + /// The SBOM digest for artifact context. + /// + public required string SbomDigest { get; init; } + + /// + /// Package URL of the vulnerable component. + /// + public required string ComponentPurl { get; init; } + + /// + /// Vulnerability ID. + /// + public required string VulnId { get; init; } + + /// + /// Vulnerability source. + /// + public required string VulnSource { get; init; } + + /// + /// Affected version range. + /// + public required string AffectedRange { get; init; } + + /// + /// Sink symbol ID to find paths to. + /// + public required string SinkSymbolId { get; init; } + + /// + /// Sink taxonomy type. + /// + public required string SinkType { get; init; } + + /// + /// The call graph to use for path finding. + /// + public required RichGraph CallGraph { get; init; } + + /// + /// BLAKE3 digest of the call graph. + /// + public required string CallgraphDigest { get; init; } + + /// + /// Maximum number of witnesses to generate. + /// + public int MaxWitnesses { get; init; } = 10; + + /// + /// Optional attack surface digest. + /// + public string? SurfaceDigest { get; init; } + + /// + /// Optional analysis config digest. + /// + public string? AnalysisConfigDigest { get; init; } + + /// + /// Optional build ID. + /// + public string? BuildId { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitness.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitness.cs new file mode 100644 index 00000000..a94dba19 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitness.cs @@ -0,0 +1,256 @@ +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.Reachability.Witnesses; + +/// +/// A DSSE-signable path witness documenting the call path from entrypoint to vulnerable sink. +/// Conforms to stellaops.witness.v1 schema. +/// +public sealed record PathWitness +{ + /// + /// Schema version identifier. + /// + [JsonPropertyName("witness_schema")] + public string WitnessSchema { get; init; } = Witnesses.WitnessSchema.Version; + + /// + /// Content-addressed witness ID (e.g., "wit:sha256:..."). + /// + [JsonPropertyName("witness_id")] + public required string WitnessId { get; init; } + + /// + /// The artifact (SBOM, component) this witness relates to. + /// + [JsonPropertyName("artifact")] + public required WitnessArtifact Artifact { get; init; } + + /// + /// The vulnerability this witness concerns. + /// + [JsonPropertyName("vuln")] + public required WitnessVuln Vuln { get; init; } + + /// + /// The entrypoint from which the path originates. + /// + [JsonPropertyName("entrypoint")] + public required WitnessEntrypoint Entrypoint { get; init; } + + /// + /// The call path from entrypoint to sink, ordered from caller to callee. + /// + [JsonPropertyName("path")] + public required IReadOnlyList Path { get; init; } + + /// + /// The vulnerable sink reached at the end of the path. + /// + [JsonPropertyName("sink")] + public required WitnessSink Sink { get; init; } + + /// + /// Detected gates (guards, authentication, validation) along the path. + /// + [JsonPropertyName("gates")] + public IReadOnlyList? Gates { get; init; } + + /// + /// Evidence digests and build context for reproducibility. + /// + [JsonPropertyName("evidence")] + public required WitnessEvidence Evidence { get; init; } + + /// + /// When this witness was generated (UTC ISO-8601). + /// + [JsonPropertyName("observed_at")] + public required DateTimeOffset ObservedAt { get; init; } +} + +/// +/// Artifact context for a witness. +/// +public sealed record WitnessArtifact +{ + /// + /// SHA-256 digest of the SBOM. + /// + [JsonPropertyName("sbom_digest")] + public required string SbomDigest { get; init; } + + /// + /// Package URL of the vulnerable component. + /// + [JsonPropertyName("component_purl")] + public required string ComponentPurl { get; init; } +} + +/// +/// Vulnerability information for a witness. +/// +public sealed record WitnessVuln +{ + /// + /// Vulnerability identifier (e.g., "CVE-2024-12345"). + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Vulnerability source (e.g., "NVD", "OSV", "GHSA"). + /// + [JsonPropertyName("source")] + public required string Source { get; init; } + + /// + /// Affected version range expression. + /// + [JsonPropertyName("affected_range")] + public required string AffectedRange { get; init; } +} + +/// +/// Entrypoint that starts the reachability path. +/// +public sealed record WitnessEntrypoint +{ + /// + /// Kind of entrypoint (http, grpc, cli, job, event). + /// + [JsonPropertyName("kind")] + public required string Kind { get; init; } + + /// + /// Human-readable name (e.g., "GET /api/users/{id}"). + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Canonical symbol ID for the entrypoint. + /// + [JsonPropertyName("symbol_id")] + public required string SymbolId { get; init; } +} + +/// +/// A single step in the call path from entrypoint to sink. +/// +public sealed record PathStep +{ + /// + /// Human-readable symbol name. + /// + [JsonPropertyName("symbol")] + public required string Symbol { get; init; } + + /// + /// Canonical symbol ID. + /// + [JsonPropertyName("symbol_id")] + public required string SymbolId { get; init; } + + /// + /// Source file path (null for external/binary symbols). + /// + [JsonPropertyName("file")] + public string? File { get; init; } + + /// + /// Line number in source file (1-based). + /// + [JsonPropertyName("line")] + public int? Line { get; init; } + + /// + /// Column number in source file (1-based). + /// + [JsonPropertyName("column")] + public int? Column { get; init; } +} + +/// +/// The vulnerable sink at the end of the reachability path. +/// +public sealed record WitnessSink +{ + /// + /// Human-readable symbol name. + /// + [JsonPropertyName("symbol")] + public required string Symbol { get; init; } + + /// + /// Canonical symbol ID. + /// + [JsonPropertyName("symbol_id")] + public required string SymbolId { get; init; } + + /// + /// Sink taxonomy type (e.g., "deserialization", "sql_injection", "path_traversal"). + /// + [JsonPropertyName("sink_type")] + public required string SinkType { get; init; } +} + +/// +/// A detected gate (guard/mitigating control) along the path. +/// +public sealed record DetectedGate +{ + /// + /// Gate type (authRequired, inputValidation, rateLimited, etc.). + /// + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// + /// Symbol that implements the gate. + /// + [JsonPropertyName("guard_symbol")] + public required string GuardSymbol { get; init; } + + /// + /// Confidence level (0.0 - 1.0). + /// + [JsonPropertyName("confidence")] + public required double Confidence { get; init; } + + /// + /// Human-readable detail about the gate. + /// + [JsonPropertyName("detail")] + public string? Detail { get; init; } +} + +/// +/// Evidence digests for reproducibility and audit trail. +/// +public sealed record WitnessEvidence +{ + /// + /// BLAKE3 digest of the call graph used. + /// + [JsonPropertyName("callgraph_digest")] + public required string CallgraphDigest { get; init; } + + /// + /// SHA-256 digest of the attack surface manifest. + /// + [JsonPropertyName("surface_digest")] + public string? SurfaceDigest { get; init; } + + /// + /// SHA-256 digest of the analysis configuration. + /// + [JsonPropertyName("analysis_config_digest")] + public string? AnalysisConfigDigest { get; init; } + + /// + /// Build identifier for the analyzed artifact. + /// + [JsonPropertyName("build_id")] + public string? BuildId { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitnessBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitnessBuilder.cs new file mode 100644 index 00000000..57708926 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitnessBuilder.cs @@ -0,0 +1,378 @@ +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using StellaOps.Cryptography; +using StellaOps.Scanner.Reachability.Gates; + +namespace StellaOps.Scanner.Reachability.Witnesses; + +/// +/// Builds path witnesses from reachability analysis results. +/// +public sealed class PathWitnessBuilder : IPathWitnessBuilder +{ + private readonly ICryptoHash _cryptoHash; + private readonly CompositeGateDetector? _gateDetector; + private readonly TimeProvider _timeProvider; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = false + }; + + /// + /// Creates a new PathWitnessBuilder. + /// + /// Crypto hash service for witness ID generation. + /// Time provider for timestamps. + /// Optional gate detector for identifying guards along paths. + public PathWitnessBuilder( + ICryptoHash cryptoHash, + TimeProvider timeProvider, + CompositeGateDetector? gateDetector = null) + { + _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _gateDetector = gateDetector; + } + + /// + public async Task BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // Find path from entrypoint to sink using BFS + var path = FindPath(request.CallGraph, request.EntrypointSymbolId, request.SinkSymbolId); + if (path is null || path.Count == 0) + { + return null; // No path found + } + + // Infer language from the call graph nodes + var language = request.CallGraph.Nodes?.FirstOrDefault()?.Lang ?? "unknown"; + + // Detect gates along the path + var gates = _gateDetector is not null + ? await DetectGatesAsync(request.CallGraph, path, language, cancellationToken).ConfigureAwait(false) + : null; + + // Get sink node info + var sinkNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.SymbolId == request.SinkSymbolId); + var sinkSymbol = sinkNode?.Display ?? sinkNode?.Symbol?.Demangled ?? request.SinkSymbolId; + + // Build the witness + var witness = new PathWitness + { + WitnessId = string.Empty, // Will be set after hashing + Artifact = new WitnessArtifact + { + SbomDigest = request.SbomDigest, + ComponentPurl = request.ComponentPurl + }, + Vuln = new WitnessVuln + { + Id = request.VulnId, + Source = request.VulnSource, + AffectedRange = request.AffectedRange + }, + Entrypoint = new WitnessEntrypoint + { + Kind = request.EntrypointKind, + Name = request.EntrypointName, + SymbolId = request.EntrypointSymbolId + }, + Path = path, + Sink = new WitnessSink + { + Symbol = sinkSymbol, + SymbolId = request.SinkSymbolId, + SinkType = request.SinkType + }, + Gates = gates, + Evidence = new WitnessEvidence + { + CallgraphDigest = request.CallgraphDigest, + SurfaceDigest = request.SurfaceDigest, + AnalysisConfigDigest = request.AnalysisConfigDigest, + BuildId = request.BuildId + }, + ObservedAt = _timeProvider.GetUtcNow() + }; + + // Compute witness ID from canonical content + var witnessId = ComputeWitnessId(witness); + witness = witness with { WitnessId = witnessId }; + + return witness; + } + + /// + public async IAsyncEnumerable BuildAllAsync( + BatchWitnessRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // Find all roots (entrypoints) in the graph + var roots = request.CallGraph.Roots; + if (roots is null || roots.Count == 0) + { + yield break; + } + + var witnessCount = 0; + + foreach (var root in roots) + { + if (witnessCount >= request.MaxWitnesses) + { + yield break; + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Look up the node to get the symbol name + var rootNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.Id == root.Id); + + var singleRequest = new PathWitnessRequest + { + SbomDigest = request.SbomDigest, + ComponentPurl = request.ComponentPurl, + VulnId = request.VulnId, + VulnSource = request.VulnSource, + AffectedRange = request.AffectedRange, + EntrypointSymbolId = rootNode?.SymbolId ?? root.Id, + EntrypointKind = root.Phase ?? "unknown", + EntrypointName = rootNode?.Display ?? root.Source ?? root.Id, + SinkSymbolId = request.SinkSymbolId, + SinkType = request.SinkType, + CallGraph = request.CallGraph, + CallgraphDigest = request.CallgraphDigest, + SurfaceDigest = request.SurfaceDigest, + AnalysisConfigDigest = request.AnalysisConfigDigest, + BuildId = request.BuildId + }; + + var witness = await BuildAsync(singleRequest, cancellationToken).ConfigureAwait(false); + if (witness is not null) + { + witnessCount++; + yield return witness; + } + } + } + + /// + /// Finds the shortest path from source to target using BFS. + /// + private List? FindPath(RichGraph graph, string sourceSymbolId, string targetSymbolId) + { + if (graph.Nodes is null || graph.Edges is null) + { + return null; + } + + // Build node ID to symbol ID mapping + var nodeIdToSymbolId = graph.Nodes.ToDictionary( + n => n.Id, + n => n.SymbolId, + StringComparer.Ordinal); + + // Build adjacency list using From/To (node IDs) mapped to symbol IDs + var adjacency = new Dictionary>(StringComparer.Ordinal); + foreach (var edge in graph.Edges) + { + if (string.IsNullOrEmpty(edge.From) || string.IsNullOrEmpty(edge.To)) + { + continue; + } + + // Map node IDs to symbol IDs + if (!nodeIdToSymbolId.TryGetValue(edge.From, out var fromSymbolId) || + !nodeIdToSymbolId.TryGetValue(edge.To, out var toSymbolId)) + { + continue; + } + + if (!adjacency.TryGetValue(fromSymbolId, out var neighbors)) + { + neighbors = new List(); + adjacency[fromSymbolId] = neighbors; + } + neighbors.Add(toSymbolId); + } + + // BFS to find shortest path + var visited = new HashSet(StringComparer.Ordinal); + var parent = new Dictionary(StringComparer.Ordinal); + var queue = new Queue(); + + queue.Enqueue(sourceSymbolId); + visited.Add(sourceSymbolId); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + + if (current.Equals(targetSymbolId, StringComparison.Ordinal)) + { + // Reconstruct path + return ReconstructPath(graph, parent, sourceSymbolId, targetSymbolId); + } + + if (!adjacency.TryGetValue(current, out var neighbors)) + { + continue; + } + + // Sort neighbors for deterministic ordering + foreach (var neighbor in neighbors.Order(StringComparer.Ordinal)) + { + if (visited.Add(neighbor)) + { + parent[neighbor] = current; + queue.Enqueue(neighbor); + } + } + } + + return null; // No path found + } + + /// + /// Reconstructs the path from parent map. + /// + private static List ReconstructPath( + RichGraph graph, + Dictionary parent, + string source, + string target) + { + var path = new List(); + var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + + var current = target; + while (current is not null) + { + nodeMap.TryGetValue(current, out var node); + + // Extract source file/line from Attributes if available + string? file = null; + int? line = null; + int? column = null; + + if (node?.Attributes is not null) + { + if (node.Attributes.TryGetValue("file", out var fileValue)) + { + file = fileValue; + } + if (node.Attributes.TryGetValue("line", out var lineValue) && int.TryParse(lineValue, out var parsedLine)) + { + line = parsedLine; + } + if (node.Attributes.TryGetValue("column", out var colValue) && int.TryParse(colValue, out var parsedCol)) + { + column = parsedCol; + } + } + + path.Add(new PathStep + { + Symbol = node?.Display ?? node?.Symbol?.Demangled ?? current, + SymbolId = current, + File = file, + Line = line, + Column = column + }); + + if (current.Equals(source, StringComparison.Ordinal)) + { + break; + } + + parent.TryGetValue(current, out current); + } + + path.Reverse(); // Reverse to get source → target order + return path; + } + + /// + /// Detects gates along the path using the composite gate detector. + /// + private async Task?> DetectGatesAsync( + RichGraph graph, + List path, + string language, + CancellationToken cancellationToken) + { + if (_gateDetector is null || path.Count == 0) + { + return null; + } + + // Build source file map for the path + var sourceFiles = new Dictionary(StringComparer.Ordinal); + var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + + foreach (var step in path) + { + if (nodeMap.TryGetValue(step.SymbolId, out var node) && + node.Attributes is not null && + node.Attributes.TryGetValue("file", out var file)) + { + sourceFiles[step.SymbolId] = file; + } + } + + var context = new CallPathContext + { + CallPath = path.Select(s => s.SymbolId).ToList(), + SourceFiles = sourceFiles.Count > 0 ? sourceFiles : null, + Language = language + }; + + var result = await _gateDetector.DetectAllAsync(context, cancellationToken).ConfigureAwait(false); + + if (result.Gates.Count == 0) + { + return null; + } + + return result.Gates.Select(g => new DetectedGate + { + Type = g.Type.ToString(), + GuardSymbol = g.GuardSymbol, + Confidence = g.Confidence, + Detail = g.Detail + }).ToList(); + } + + /// + /// Computes a content-addressed witness ID. + /// + private string ComputeWitnessId(PathWitness witness) + { + // Create a canonical representation for hashing (excluding witness_id itself) + var canonical = new + { + witness.WitnessSchema, + witness.Artifact, + witness.Vuln, + witness.Entrypoint, + witness.Path, + witness.Sink, + witness.Evidence + }; + + var json = JsonSerializer.SerializeToUtf8Bytes(canonical, JsonOptions); + var hash = _cryptoHash.ComputePrefixedHashForPurpose(json, HashPurpose.Content); + + return $"{WitnessSchema.WitnessIdPrefix}{hash}"; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessSchema.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessSchema.cs new file mode 100644 index 00000000..deb682d6 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessSchema.cs @@ -0,0 +1,22 @@ +namespace StellaOps.Scanner.Reachability.Witnesses; + +/// +/// Constants for the stellaops.witness.v1 schema. +/// +public static class WitnessSchema +{ + /// + /// Current witness schema version. + /// + public const string Version = "stellaops.witness.v1"; + + /// + /// Prefix for witness IDs. + /// + public const string WitnessIdPrefix = "wit:"; + + /// + /// Default DSSE payload type for witnesses. + /// + public const string DssePayloadType = "application/vnd.stellaops.witness.v1+json"; +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/BoundaryProof.cs b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/BoundaryProof.cs new file mode 100644 index 00000000..a07bb3fd --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/BoundaryProof.cs @@ -0,0 +1,216 @@ +// ----------------------------------------------------------------------------- +// BoundaryProof.cs +// Sprint: SPRINT_3800_0001_0001_evidence_api_models +// Description: Boundary proof model for surface exposure and security controls. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.SmartDiff.Detection; + +/// +/// Boundary proof describing surface exposure, authentication, and security controls. +/// Used to determine the attack surface and protective measures for a finding. +/// +public sealed record BoundaryProof +{ + /// + /// Kind of boundary (network, file, ipc, process). + /// + [JsonPropertyName("kind")] + public string Kind { get; init; } = string.Empty; + + /// + /// Surface descriptor (what is exposed). + /// + [JsonPropertyName("surface")] + public BoundarySurface? Surface { get; init; } + + /// + /// Exposure descriptor (how it's exposed). + /// + [JsonPropertyName("exposure")] + public BoundaryExposure? Exposure { get; init; } + + /// + /// Authentication requirements. + /// + [JsonPropertyName("auth")] + public BoundaryAuth? Auth { get; init; } + + /// + /// Security controls protecting the boundary. + /// + [JsonPropertyName("controls")] + public IReadOnlyList? Controls { get; init; } + + /// + /// When the boundary was last verified. + /// + [JsonPropertyName("last_seen")] + public DateTimeOffset LastSeen { get; init; } + + /// + /// Confidence score for this boundary proof (0.0 to 1.0). + /// + [JsonPropertyName("confidence")] + public double Confidence { get; init; } + + /// + /// Source of this boundary proof (static_analysis, runtime_observation, config). + /// + [JsonPropertyName("source")] + public string? Source { get; init; } + + /// + /// Reference to the evidence source (graph hash, scan ID, etc.). + /// + [JsonPropertyName("evidence_ref")] + public string? EvidenceRef { get; init; } +} + +/// +/// Describes what attack surface is exposed. +/// +public sealed record BoundarySurface +{ + /// + /// Type of surface (api, web, cli, library, file, socket). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// Protocol (http, https, grpc, tcp, udp, unix). + /// + [JsonPropertyName("protocol")] + public string? Protocol { get; init; } + + /// + /// Port number if network-exposed. + /// + [JsonPropertyName("port")] + public int? Port { get; init; } + + /// + /// Host or interface binding. + /// + [JsonPropertyName("host")] + public string? Host { get; init; } + + /// + /// Path or route pattern. + /// + [JsonPropertyName("path")] + public string? Path { get; init; } +} + +/// +/// Describes how the surface is exposed. +/// +public sealed record BoundaryExposure +{ + /// + /// Exposure level (public, internal, private, localhost). + /// + [JsonPropertyName("level")] + public string Level { get; init; } = string.Empty; + + /// + /// Whether the exposure is internet-facing. + /// + [JsonPropertyName("internet_facing")] + public bool InternetFacing { get; init; } + + /// + /// Network zone (dmz, internal, trusted, untrusted). + /// + [JsonPropertyName("zone")] + public string? Zone { get; init; } + + /// + /// Whether behind a load balancer or proxy. + /// + [JsonPropertyName("behind_proxy")] + public bool? BehindProxy { get; init; } + + /// + /// Expected client types (browser, api_client, service, any). + /// + [JsonPropertyName("client_types")] + public IReadOnlyList? ClientTypes { get; init; } +} + +/// +/// Describes authentication requirements at the boundary. +/// +public sealed record BoundaryAuth +{ + /// + /// Whether authentication is required. + /// + [JsonPropertyName("required")] + public bool Required { get; init; } + + /// + /// Authentication type (jwt, oauth2, basic, api_key, mtls, session). + /// + [JsonPropertyName("type")] + public string? Type { get; init; } + + /// + /// Required roles or scopes. + /// + [JsonPropertyName("roles")] + public IReadOnlyList? Roles { get; init; } + + /// + /// Authentication provider or issuer. + /// + [JsonPropertyName("provider")] + public string? Provider { get; init; } + + /// + /// Whether MFA is required. + /// + [JsonPropertyName("mfa_required")] + public bool? MfaRequired { get; init; } +} + +/// +/// Describes a security control at the boundary. +/// +public sealed record BoundaryControl +{ + /// + /// Type of control (rate_limit, waf, input_validation, output_encoding, etc.). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// Whether the control is currently active. + /// + [JsonPropertyName("active")] + public bool Active { get; init; } + + /// + /// Control configuration or policy reference. + /// + [JsonPropertyName("config")] + public string? Config { get; init; } + + /// + /// Effectiveness rating (high, medium, low). + /// + [JsonPropertyName("effectiveness")] + public string? Effectiveness { get; init; } + + /// + /// When the control was last verified. + /// + [JsonPropertyName("verified_at")] + public DateTimeOffset? VerifiedAt { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs new file mode 100644 index 00000000..3c1015a4 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs @@ -0,0 +1,179 @@ +// ----------------------------------------------------------------------------- +// VexEvidence.cs +// Sprint: SPRINT_3800_0001_0001_evidence_api_models +// Description: VEX (Vulnerability Exploitability eXchange) evidence model. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.SmartDiff.Detection; + +/// +/// VEX (Vulnerability Exploitability eXchange) evidence for a vulnerability. +/// Captures vendor/first-party statements about whether a vulnerability is exploitable. +/// +public sealed record VexEvidence +{ + /// + /// VEX status: not_affected, affected, fixed, under_investigation. + /// + [JsonPropertyName("status")] + public VexStatus Status { get; init; } + + /// + /// Justification for the status (per OpenVEX specification). + /// + [JsonPropertyName("justification")] + public VexJustification? Justification { get; init; } + + /// + /// Human-readable impact statement explaining why not affected. + /// + [JsonPropertyName("impact")] + public string? Impact { get; init; } + + /// + /// Human-readable action statement (remediation steps). + /// + [JsonPropertyName("action")] + public string? Action { get; init; } + + /// + /// Reference to the VEX document or DSSE attestation. + /// + [JsonPropertyName("attestation_ref")] + public string? AttestationRef { get; init; } + + /// + /// VEX document ID. + /// + [JsonPropertyName("document_id")] + public string? DocumentId { get; init; } + + /// + /// When the VEX statement was issued. + /// + [JsonPropertyName("issued_at")] + public DateTimeOffset? IssuedAt { get; init; } + + /// + /// When the VEX statement was last updated. + /// + [JsonPropertyName("updated_at")] + public DateTimeOffset? UpdatedAt { get; init; } + + /// + /// When the VEX statement expires. + /// + [JsonPropertyName("expires_at")] + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Source of the VEX statement (vendor, first_party, third_party, coordinator). + /// + [JsonPropertyName("source")] + public VexSource? Source { get; init; } + + /// + /// Affected product or component reference (PURL). + /// + [JsonPropertyName("product_ref")] + public string? ProductRef { get; init; } + + /// + /// Vulnerability ID (CVE, GHSA, etc.). + /// + [JsonPropertyName("vulnerability_id")] + public string? VulnerabilityId { get; init; } + + /// + /// Confidence in the VEX statement (0.0 to 1.0). + /// Higher confidence for vendor statements, lower for third-party. + /// + [JsonPropertyName("confidence")] + public double Confidence { get; init; } = 1.0; + + /// + /// Whether the VEX statement is still valid (not expired). + /// + [JsonIgnore] + public bool IsValid => ExpiresAt is null || ExpiresAt > DateTimeOffset.UtcNow; + + /// + /// Whether this VEX statement indicates the vulnerability is not exploitable. + /// + [JsonIgnore] + public bool IsNotAffected => Status == VexStatus.NotAffected; + + /// + /// Additional context or notes about the VEX statement. + /// + [JsonPropertyName("notes")] + public IReadOnlyList? Notes { get; init; } +} + +/// +/// VEX status values per OpenVEX specification. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum VexStatus +{ + /// + /// The vulnerability is not exploitable in this context. + /// + [JsonPropertyName("not_affected")] + NotAffected, + + /// + /// The vulnerability is exploitable. + /// + [JsonPropertyName("affected")] + Affected, + + /// + /// The vulnerability has been fixed. + /// + [JsonPropertyName("fixed")] + Fixed, + + /// + /// The vulnerability is under investigation. + /// + [JsonPropertyName("under_investigation")] + UnderInvestigation +} + +// NOTE: VexJustification is defined in VexCandidateModels.cs to avoid duplication + +/// +/// Source of a VEX statement. +/// +public sealed record VexSource +{ + /// + /// Source type (vendor, first_party, third_party, coordinator, community). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// Name of the source organization. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } + + /// + /// URL to the source's VEX feed or website. + /// + [JsonPropertyName("url")] + public string? Url { get; init; } + + /// + /// Trust level (high, medium, low). + /// Vendor and first-party are typically high; third-party varies. + /// + [JsonPropertyName("trust_level")] + public string? TrustLevel { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/Events/EpssUpdatedEvent.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/Events/EpssUpdatedEvent.cs new file mode 100644 index 00000000..eb807400 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/Events/EpssUpdatedEvent.cs @@ -0,0 +1,195 @@ +// ----------------------------------------------------------------------------- +// EpssUpdatedEvent.cs +// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage +// Task: EPSS-3410-011 +// Description: Event published when EPSS data is successfully updated. +// ----------------------------------------------------------------------------- + +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.Storage.Epss.Events; + +/// +/// Event published when EPSS data is successfully ingested. +/// Event type: "epss.updated@1" +/// +public sealed record EpssUpdatedEvent +{ + /// + /// Event type identifier for routing. + /// + public const string EventType = "epss.updated@1"; + + /// + /// Event version for schema evolution. + /// + public const int Version = 1; + + /// + /// Unique identifier for this event instance. + /// + [JsonPropertyName("event_id")] + public required Guid EventId { get; init; } + + /// + /// UTC timestamp when the event occurred. + /// + [JsonPropertyName("occurred_at_utc")] + public required DateTimeOffset OccurredAtUtc { get; init; } + + /// + /// The import run ID that produced this update. + /// + [JsonPropertyName("import_run_id")] + public required Guid ImportRunId { get; init; } + + /// + /// The EPSS model date (YYYY-MM-DD) that was imported. + /// + [JsonPropertyName("model_date")] + public required DateOnly ModelDate { get; init; } + + /// + /// The EPSS model version tag (e.g., "v2025.12.17"). + /// + [JsonPropertyName("model_version_tag")] + public string? ModelVersionTag { get; init; } + + /// + /// The published date from the EPSS data. + /// + [JsonPropertyName("published_date")] + public DateOnly? PublishedDate { get; init; } + + /// + /// Total number of CVEs in the snapshot. + /// + [JsonPropertyName("row_count")] + public required int RowCount { get; init; } + + /// + /// Number of distinct CVE IDs in the snapshot. + /// + [JsonPropertyName("distinct_cve_count")] + public required int DistinctCveCount { get; init; } + + /// + /// SHA256 hash of the decompressed CSV content. + /// + [JsonPropertyName("content_hash")] + public string? ContentHash { get; init; } + + /// + /// Source URI (online URL or bundle path). + /// + [JsonPropertyName("source_uri")] + public required string SourceUri { get; init; } + + /// + /// Duration of the ingestion in milliseconds. + /// + [JsonPropertyName("duration_ms")] + public required long DurationMs { get; init; } + + /// + /// Summary of material changes detected. + /// + [JsonPropertyName("change_summary")] + public EpssChangeSummary? ChangeSummary { get; init; } + + /// + /// Creates an idempotency key for this event based on model date and import run. + /// + public string GetIdempotencyKey() + => $"epss.updated:{ModelDate:yyyy-MM-dd}:{ImportRunId:N}"; +} + +/// +/// Summary of material changes in an EPSS update. +/// +public sealed record EpssChangeSummary +{ + /// + /// Number of CVEs newly scored (first appearance). + /// + [JsonPropertyName("new_scored")] + public int NewScored { get; init; } + + /// + /// Number of CVEs that crossed the high threshold upward. + /// + [JsonPropertyName("crossed_high")] + public int CrossedHigh { get; init; } + + /// + /// Number of CVEs that crossed the high threshold downward. + /// + [JsonPropertyName("crossed_low")] + public int CrossedLow { get; init; } + + /// + /// Number of CVEs with a big jump up in score. + /// + [JsonPropertyName("big_jump_up")] + public int BigJumpUp { get; init; } + + /// + /// Number of CVEs with a big jump down in score. + /// + [JsonPropertyName("big_jump_down")] + public int BigJumpDown { get; init; } + + /// + /// Number of CVEs that entered the top percentile. + /// + [JsonPropertyName("top_percentile")] + public int TopPercentile { get; init; } + + /// + /// Number of CVEs that left the top percentile. + /// + [JsonPropertyName("left_top_percentile")] + public int LeftTopPercentile { get; init; } + + /// + /// Total number of CVEs with any material change. + /// + [JsonPropertyName("total_changed")] + public int TotalChanged { get; init; } +} + +/// +/// Builder for creating instances. +/// +public static class EpssUpdatedEventBuilder +{ + public static EpssUpdatedEvent Create( + Guid importRunId, + DateOnly modelDate, + string sourceUri, + int rowCount, + int distinctCveCount, + long durationMs, + TimeProvider timeProvider, + string? modelVersionTag = null, + DateOnly? publishedDate = null, + string? contentHash = null, + EpssChangeSummary? changeSummary = null) + { + return new EpssUpdatedEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = timeProvider.GetUtcNow(), + ImportRunId = importRunId, + ModelDate = modelDate, + ModelVersionTag = modelVersionTag, + PublishedDate = publishedDate, + RowCount = rowCount, + DistinctCveCount = distinctCveCount, + ContentHash = contentHash, + SourceUri = sourceUri, + DurationMs = durationMs, + ChangeSummary = changeSummary + }; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs index 9f12219c..bb541a62 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs @@ -82,8 +82,17 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // EPSS ingestion services services.AddSingleton(); services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Witness storage (Sprint: SPRINT_3700_0001_0001) + services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/013_witness_storage.sql b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/013_witness_storage.sql new file mode 100644 index 00000000..635e29e3 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/013_witness_storage.sql @@ -0,0 +1,60 @@ +-- Migration: 013_witness_storage.sql +-- Sprint: SPRINT_3700_0001_0001_witness_foundation +-- Task: WIT-011 +-- Description: Creates tables for DSSE-signed path witnesses and witness storage. + +-- Witness storage for reachability path proofs +CREATE TABLE IF NOT EXISTS scanner.witnesses ( + witness_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + witness_hash TEXT NOT NULL, -- BLAKE3 hash of witness payload + schema_version TEXT NOT NULL DEFAULT 'stellaops.witness.v1', + witness_type TEXT NOT NULL, -- 'reachability_path', 'gate_proof', etc. + + -- Reference to the graph/analysis that produced this witness + graph_hash TEXT NOT NULL, -- BLAKE3 hash of source rich graph + scan_id UUID, + run_id UUID, + + -- Witness content + payload_json JSONB NOT NULL, -- PathWitness JSON + dsse_envelope JSONB, -- DSSE signed envelope (nullable until signed) + + -- Provenance + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + signed_at TIMESTAMPTZ, + signer_key_id TEXT, + + -- Indexing + entrypoint_fqn TEXT, -- For quick lookup by entrypoint + sink_cve TEXT, -- For quick lookup by CVE + + CONSTRAINT uk_witness_hash UNIQUE (witness_hash) +); + +-- Index for efficient lookups +CREATE INDEX IF NOT EXISTS ix_witnesses_graph_hash ON scanner.witnesses (graph_hash); +CREATE INDEX IF NOT EXISTS ix_witnesses_scan_id ON scanner.witnesses (scan_id) WHERE scan_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS ix_witnesses_sink_cve ON scanner.witnesses (sink_cve) WHERE sink_cve IS NOT NULL; +CREATE INDEX IF NOT EXISTS ix_witnesses_entrypoint ON scanner.witnesses (entrypoint_fqn) WHERE entrypoint_fqn IS NOT NULL; +CREATE INDEX IF NOT EXISTS ix_witnesses_created_at ON scanner.witnesses (created_at DESC); + +-- GIN index for JSONB queries on payload +CREATE INDEX IF NOT EXISTS ix_witnesses_payload_gin ON scanner.witnesses USING gin (payload_json jsonb_path_ops); + +-- Witness verification log (for audit trail) +CREATE TABLE IF NOT EXISTS scanner.witness_verifications ( + verification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + witness_id UUID NOT NULL REFERENCES scanner.witnesses(witness_id), + verified_at TIMESTAMPTZ NOT NULL DEFAULT now(), + verified_by TEXT, -- 'system', 'api', 'cli' + verification_status TEXT NOT NULL, -- 'valid', 'invalid', 'expired' + verification_error TEXT, + verifier_key_id TEXT +); + +CREATE INDEX IF NOT EXISTS ix_witness_verifications_witness_id ON scanner.witness_verifications (witness_id); + +COMMENT ON TABLE scanner.witnesses IS 'DSSE-signed path witnesses for reachability proofs (stellaops.witness.v1)'; +COMMENT ON TABLE scanner.witness_verifications IS 'Audit log of witness verification attempts'; +COMMENT ON COLUMN scanner.witnesses.witness_hash IS 'BLAKE3 hash of witness payload for deduplication and integrity'; +COMMENT ON COLUMN scanner.witnesses.dsse_envelope IS 'Dead Simple Signing Envelope (DSSE) containing the signed witness'; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs index aae03878..8376ea35 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs @@ -12,4 +12,7 @@ internal static class MigrationIds public const string EpssIntegration = "008_epss_integration.sql"; public const string CallGraphTables = "009_call_graph_tables.sql"; public const string ReachabilityDriftTables = "010_reachability_drift_tables.sql"; + public const string EpssRawLayer = "011_epss_raw_layer.sql"; + public const string EpssSignalLayer = "012_epss_signal_layer.sql"; + public const string WitnessStorage = "013_witness_storage.sql"; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/IWitnessRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/IWitnessRepository.cs new file mode 100644 index 00000000..3389c3d0 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/IWitnessRepository.cs @@ -0,0 +1,89 @@ +// ----------------------------------------------------------------------------- +// IWitnessRepository.cs +// Sprint: SPRINT_3700_0001_0001_witness_foundation +// Task: WIT-012 +// Description: Repository interface for path witness storage and retrieval. +// ----------------------------------------------------------------------------- + +namespace StellaOps.Scanner.Storage.Repositories; + +/// +/// Repository for DSSE-signed path witnesses. +/// +public interface IWitnessRepository +{ + /// + /// Stores a witness and returns the assigned ID. + /// + Task StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default); + + /// + /// Retrieves a witness by its ID. + /// + Task GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default); + + /// + /// Retrieves a witness by its hash. + /// + Task GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default); + + /// + /// Retrieves all witnesses for a given graph hash. + /// + Task> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default); + + /// + /// Retrieves witnesses for a given scan. + /// + Task> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default); + + /// + /// Retrieves witnesses for a given CVE. + /// + Task> GetByCveAsync(string cveId, CancellationToken cancellationToken = default); + + /// + /// Updates a witness with a DSSE envelope after signing. + /// + Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default); + + /// + /// Records a verification attempt for a witness. + /// + Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default); +} + +/// +/// Record representing a stored witness. +/// +public sealed record WitnessRecord +{ + public Guid WitnessId { get; init; } + public required string WitnessHash { get; init; } + public string SchemaVersion { get; init; } = "stellaops.witness.v1"; + public required string WitnessType { get; init; } + public required string GraphHash { get; init; } + public Guid? ScanId { get; init; } + public Guid? RunId { get; init; } + public required string PayloadJson { get; init; } + public string? DsseEnvelope { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? SignedAt { get; init; } + public string? SignerKeyId { get; init; } + public string? EntrypointFqn { get; init; } + public string? SinkCve { get; init; } +} + +/// +/// Record representing a witness verification attempt. +/// +public sealed record WitnessVerificationRecord +{ + public Guid VerificationId { get; init; } + public required Guid WitnessId { get; init; } + public DateTimeOffset VerifiedAt { get; init; } + public string? VerifiedBy { get; init; } + public required string VerificationStatus { get; init; } + public string? VerificationError { get; init; } + public string? VerifierKeyId { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs new file mode 100644 index 00000000..a6aad4b5 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs @@ -0,0 +1,275 @@ +// ----------------------------------------------------------------------------- +// PostgresWitnessRepository.cs +// Sprint: SPRINT_3700_0001_0001_witness_foundation +// Task: WIT-012 +// Description: Postgres implementation of IWitnessRepository for witness storage. +// ----------------------------------------------------------------------------- + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using StellaOps.Scanner.Storage.Postgres; + +namespace StellaOps.Scanner.Storage.Repositories; + +/// +/// Postgres implementation of . +/// +public sealed class PostgresWitnessRepository : IWitnessRepository +{ + private readonly ScannerDataSource _dataSource; + private readonly ILogger _logger; + + public PostgresWitnessRepository(ScannerDataSource dataSource, ILogger logger) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(witness); + + const string sql = """ + INSERT INTO scanner.witnesses ( + witness_hash, schema_version, witness_type, graph_hash, + scan_id, run_id, payload_json, dsse_envelope, created_at, + signed_at, signer_key_id, entrypoint_fqn, sink_cve + ) VALUES ( + @witness_hash, @schema_version, @witness_type, @graph_hash, + @scan_id, @run_id, @payload_json::jsonb, @dsse_envelope::jsonb, @created_at, + @signed_at, @signer_key_id, @entrypoint_fqn, @sink_cve + ) + ON CONFLICT (witness_hash) DO UPDATE SET + dsse_envelope = COALESCE(EXCLUDED.dsse_envelope, scanner.witnesses.dsse_envelope), + signed_at = COALESCE(EXCLUDED.signed_at, scanner.witnesses.signed_at), + signer_key_id = COALESCE(EXCLUDED.signer_key_id, scanner.witnesses.signer_key_id) + RETURNING witness_id + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + + cmd.Parameters.AddWithValue("witness_hash", witness.WitnessHash); + cmd.Parameters.AddWithValue("schema_version", witness.SchemaVersion); + cmd.Parameters.AddWithValue("witness_type", witness.WitnessType); + cmd.Parameters.AddWithValue("graph_hash", witness.GraphHash); + cmd.Parameters.AddWithValue("scan_id", witness.ScanId.HasValue ? witness.ScanId.Value : DBNull.Value); + cmd.Parameters.AddWithValue("run_id", witness.RunId.HasValue ? witness.RunId.Value : DBNull.Value); + cmd.Parameters.AddWithValue("payload_json", witness.PayloadJson); + cmd.Parameters.AddWithValue("dsse_envelope", string.IsNullOrEmpty(witness.DsseEnvelope) ? DBNull.Value : witness.DsseEnvelope); + cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? DateTimeOffset.UtcNow : witness.CreatedAt); + cmd.Parameters.AddWithValue("signed_at", witness.SignedAt.HasValue ? witness.SignedAt.Value : DBNull.Value); + cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(witness.SignerKeyId) ? DBNull.Value : witness.SignerKeyId); + cmd.Parameters.AddWithValue("entrypoint_fqn", string.IsNullOrEmpty(witness.EntrypointFqn) ? DBNull.Value : witness.EntrypointFqn); + cmd.Parameters.AddWithValue("sink_cve", string.IsNullOrEmpty(witness.SinkCve) ? DBNull.Value : witness.SinkCve); + + var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + var witnessId = (Guid)result!; + + _logger.LogDebug("Stored witness {WitnessId} with hash {WitnessHash}", witnessId, witness.WitnessHash); + return witnessId; + } + + public async Task GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash, + scan_id, run_id, payload_json, dsse_envelope, created_at, + signed_at, signer_key_id, entrypoint_fqn, sink_cve + FROM scanner.witnesses + WHERE witness_id = @witness_id + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("witness_id", witnessId); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return MapToRecord(reader); + } + + return null; + } + + public async Task GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(witnessHash); + + const string sql = """ + SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash, + scan_id, run_id, payload_json, dsse_envelope, created_at, + signed_at, signer_key_id, entrypoint_fqn, sink_cve + FROM scanner.witnesses + WHERE witness_hash = @witness_hash + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("witness_hash", witnessHash); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return MapToRecord(reader); + } + + return null; + } + + public async Task> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(graphHash); + + const string sql = """ + SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash, + scan_id, run_id, payload_json, dsse_envelope, created_at, + signed_at, signer_key_id, entrypoint_fqn, sink_cve + FROM scanner.witnesses + WHERE graph_hash = @graph_hash + ORDER BY created_at DESC + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("graph_hash", graphHash); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(MapToRecord(reader)); + } + + return results; + } + + public async Task> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash, + scan_id, run_id, payload_json, dsse_envelope, created_at, + signed_at, signer_key_id, entrypoint_fqn, sink_cve + FROM scanner.witnesses + WHERE scan_id = @scan_id + ORDER BY created_at DESC + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("scan_id", scanId); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(MapToRecord(reader)); + } + + return results; + } + + public async Task> GetByCveAsync(string cveId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cveId); + + const string sql = """ + SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash, + scan_id, run_id, payload_json, dsse_envelope, created_at, + signed_at, signer_key_id, entrypoint_fqn, sink_cve + FROM scanner.witnesses + WHERE sink_cve = @sink_cve + ORDER BY created_at DESC + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("sink_cve", cveId); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(MapToRecord(reader)); + } + + return results; + } + + public async Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dsseEnvelopeJson); + + const string sql = """ + UPDATE scanner.witnesses + SET dsse_envelope = @dsse_envelope::jsonb, + signed_at = @signed_at, + signer_key_id = @signer_key_id + WHERE witness_id = @witness_id + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("witness_id", witnessId); + cmd.Parameters.AddWithValue("dsse_envelope", dsseEnvelopeJson); + cmd.Parameters.AddWithValue("signed_at", DateTimeOffset.UtcNow); + cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(signerKeyId) ? DBNull.Value : signerKeyId); + + var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + if (affected > 0) + { + _logger.LogDebug("Updated DSSE envelope for witness {WitnessId}", witnessId); + } + } + + public async Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(verification); + + const string sql = """ + INSERT INTO scanner.witness_verifications ( + witness_id, verified_at, verified_by, verification_status, + verification_error, verifier_key_id + ) VALUES ( + @witness_id, @verified_at, @verified_by, @verification_status, + @verification_error, @verifier_key_id + ) + """; + + await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("witness_id", verification.WitnessId); + cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? DateTimeOffset.UtcNow : verification.VerifiedAt); + cmd.Parameters.AddWithValue("verified_by", string.IsNullOrEmpty(verification.VerifiedBy) ? DBNull.Value : verification.VerifiedBy); + cmd.Parameters.AddWithValue("verification_status", verification.VerificationStatus); + cmd.Parameters.AddWithValue("verification_error", string.IsNullOrEmpty(verification.VerificationError) ? DBNull.Value : verification.VerificationError); + cmd.Parameters.AddWithValue("verifier_key_id", string.IsNullOrEmpty(verification.VerifierKeyId) ? DBNull.Value : verification.VerifierKeyId); + + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Recorded verification for witness {WitnessId}: {Status}", verification.WitnessId, verification.VerificationStatus); + } + + private static WitnessRecord MapToRecord(NpgsqlDataReader reader) + { + return new WitnessRecord + { + WitnessId = reader.GetGuid(0), + WitnessHash = reader.GetString(1), + SchemaVersion = reader.GetString(2), + WitnessType = reader.GetString(3), + GraphHash = reader.GetString(4), + ScanId = reader.IsDBNull(5) ? null : reader.GetGuid(5), + RunId = reader.IsDBNull(6) ? null : reader.GetGuid(6), + PayloadJson = reader.GetString(7), + DsseEnvelope = reader.IsDBNull(8) ? null : reader.GetString(8), + CreatedAt = reader.GetDateTime(9), + SignedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10), + SignerKeyId = reader.IsDBNull(11) ? null : reader.GetString(11), + EntrypointFqn = reader.IsDBNull(12) ? null : reader.GetString(12), + SinkCve = reader.IsDBNull(13) ? null : reader.GetString(13) + }; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageCaseCurrent.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageCaseCurrent.cs new file mode 100644 index 00000000..cc5fd616 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageCaseCurrent.cs @@ -0,0 +1,162 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Read-only view representing the current state of a triage case, +/// combining the latest risk, reachability, and VEX data. +/// +[Keyless] +public sealed class TriageCaseCurrent +{ + /// + /// The case/finding ID. + /// + [Column("case_id")] + public Guid CaseId { get; init; } + + /// + /// The asset ID. + /// + [Column("asset_id")] + public Guid AssetId { get; init; } + + /// + /// Optional environment ID. + /// + [Column("environment_id")] + public Guid? EnvironmentId { get; init; } + + /// + /// Human-readable asset label. + /// + [Column("asset_label")] + public string AssetLabel { get; init; } = string.Empty; + + /// + /// Package URL of the affected component. + /// + [Column("purl")] + public string Purl { get; init; } = string.Empty; + + /// + /// CVE identifier (if vulnerability finding). + /// + [Column("cve_id")] + public string? CveId { get; init; } + + /// + /// Rule identifier (if policy rule finding). + /// + [Column("rule_id")] + public string? RuleId { get; init; } + + /// + /// When this finding was first seen. + /// + [Column("first_seen_at")] + public DateTimeOffset FirstSeenAt { get; init; } + + /// + /// When this finding was last seen. + /// + [Column("last_seen_at")] + public DateTimeOffset LastSeenAt { get; init; } + + // Latest risk result fields + + /// + /// Policy ID from latest risk evaluation. + /// + [Column("policy_id")] + public string? PolicyId { get; init; } + + /// + /// Policy version from latest risk evaluation. + /// + [Column("policy_version")] + public string? PolicyVersion { get; init; } + + /// + /// Inputs hash from latest risk evaluation. + /// + [Column("inputs_hash")] + public string? InputsHash { get; init; } + + /// + /// Risk score (0-100). + /// + [Column("score")] + public int? Score { get; init; } + + /// + /// Final verdict. + /// + [Column("verdict")] + public TriageVerdict? Verdict { get; init; } + + /// + /// Current triage lane. + /// + [Column("lane")] + public TriageLane? Lane { get; init; } + + /// + /// Short narrative explaining the current state. + /// + [Column("why")] + public string? Why { get; init; } + + /// + /// When the risk was last computed. + /// + [Column("risk_computed_at")] + public DateTimeOffset? RiskComputedAt { get; init; } + + // Latest reachability fields + + /// + /// Reachability determination. + /// + [Column("reachable")] + public TriageReachability Reachable { get; init; } + + /// + /// Reachability confidence (0-100). + /// + [Column("reach_confidence")] + public short? ReachConfidence { get; init; } + + // Latest VEX fields + + /// + /// VEX status. + /// + [Column("vex_status")] + public TriageVexStatus? VexStatus { get; init; } + + /// + /// VEX issuer. + /// + [Column("vex_issuer")] + public string? VexIssuer { get; init; } + + /// + /// VEX signature reference. + /// + [Column("vex_signature_ref")] + public string? VexSignatureRef { get; init; } + + /// + /// VEX source domain. + /// + [Column("vex_source_domain")] + public string? VexSourceDomain { get; init; } + + /// + /// VEX source reference. + /// + [Column("vex_source_ref")] + public string? VexSourceRef { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs new file mode 100644 index 00000000..407e321e --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs @@ -0,0 +1,120 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Signed triage decision (mute, ack, exception). Decisions are reversible via revocation. +/// +[Table("triage_decision")] +public sealed class TriageDecision +{ + /// + /// Unique identifier. + /// + [Key] + [Column("id")] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// The finding this decision applies to. + /// + [Column("finding_id")] + public Guid FindingId { get; init; } + + /// + /// Type of decision. + /// + [Column("kind")] + public TriageDecisionKind Kind { get; init; } + + /// + /// Reason code for the decision (from a controlled vocabulary). + /// + [Required] + [Column("reason_code")] + public required string ReasonCode { get; init; } + + /// + /// Optional freeform note from the decision maker. + /// + [Column("note")] + public string? Note { get; init; } + + /// + /// Reference to the policy that allowed this decision. + /// + [Column("policy_ref")] + public string? PolicyRef { get; init; } + + /// + /// Time-to-live for the decision (null = indefinite). + /// + [Column("ttl")] + public DateTimeOffset? Ttl { get; init; } + + /// + /// Authority subject (sub) of the actor who made the decision. + /// + [Required] + [Column("actor_subject")] + public required string ActorSubject { get; init; } + + /// + /// Display name of the actor. + /// + [Column("actor_display")] + public string? ActorDisplay { get; init; } + + /// + /// Reference to DSSE signature. + /// + [Column("signature_ref")] + public string? SignatureRef { get; init; } + + /// + /// Hash of the DSSE envelope. + /// + [Column("dsse_hash")] + public string? DsseHash { get; init; } + + /// + /// When the decision was created. + /// + [Column("created_at")] + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + /// + /// When the decision was revoked (null = active). + /// + [Column("revoked_at")] + public DateTimeOffset? RevokedAt { get; set; } + + /// + /// Reason for revocation. + /// + [Column("revoke_reason")] + public string? RevokeReason { get; set; } + + /// + /// Signature reference for revocation. + /// + [Column("revoke_signature_ref")] + public string? RevokeSignatureRef { get; set; } + + /// + /// DSSE hash for revocation. + /// + [Column("revoke_dsse_hash")] + public string? RevokeDsseHash { get; set; } + + /// + /// Whether this decision is currently active. + /// + [NotMapped] + public bool IsActive => RevokedAt is null; + + // Navigation property + [ForeignKey(nameof(FindingId))] + public TriageFinding? Finding { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs new file mode 100644 index 00000000..310516a1 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs @@ -0,0 +1,91 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Effective VEX status for a finding after merging multiple VEX sources. +/// Preserves provenance pointers for auditability. +/// +[Table("triage_effective_vex")] +public sealed class TriageEffectiveVex +{ + /// + /// Unique identifier. + /// + [Key] + [Column("id")] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// The finding this VEX status applies to. + /// + [Column("finding_id")] + public Guid FindingId { get; init; } + + /// + /// The effective VEX status after merging. + /// + [Column("status")] + public TriageVexStatus Status { get; init; } + + /// + /// Source domain that provided this VEX (e.g., "excititor"). + /// + [Required] + [Column("source_domain")] + public required string SourceDomain { get; init; } + + /// + /// Stable reference string to the source document. + /// + [Required] + [Column("source_ref")] + public required string SourceRef { get; init; } + + /// + /// Array of pruned VEX sources with reasons (for merge transparency). + /// + [Column("pruned_sources", TypeName = "jsonb")] + public string? PrunedSourcesJson { get; init; } + + /// + /// Hash of the DSSE envelope if signed. + /// + [Column("dsse_envelope_hash")] + public string? DsseEnvelopeHash { get; init; } + + /// + /// Reference to Rekor/ledger entry for signature verification. + /// + [Column("signature_ref")] + public string? SignatureRef { get; init; } + + /// + /// Issuer of the VEX document. + /// + [Column("issuer")] + public string? Issuer { get; init; } + + /// + /// When this VEX status became valid. + /// + [Column("valid_from")] + public DateTimeOffset ValidFrom { get; init; } = DateTimeOffset.UtcNow; + + /// + /// When this VEX status expires (null = indefinite). + /// + [Column("valid_to")] + public DateTimeOffset? ValidTo { get; init; } + + /// + /// When this record was collected. + /// + [Column("collected_at")] + public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow; + + // Navigation property + [ForeignKey(nameof(FindingId))] + public TriageFinding? Finding { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEnums.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEnums.cs new file mode 100644 index 00000000..a86a6d55 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEnums.cs @@ -0,0 +1,151 @@ +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Triage lane indicating the current workflow state of a finding. +/// +public enum TriageLane +{ + /// Finding is actively being evaluated. + Active, + + /// Finding is blocking shipment. + Blocked, + + /// Finding requires a security exception to proceed. + NeedsException, + + /// Finding is muted due to reachability analysis (not reachable). + MutedReach, + + /// Finding is muted due to VEX status (not affected). + MutedVex, + + /// Finding is mitigated by compensating controls. + Compensated +} + +/// +/// Final verdict for a triage case. +/// +public enum TriageVerdict +{ + /// Can ship - no blocking issues. + Ship, + + /// Cannot ship - blocking issues present. + Block, + + /// Exception granted - can ship with documented exception. + Exception +} + +/// +/// Reachability determination result. +/// +public enum TriageReachability +{ + /// Vulnerable code is reachable. + Yes, + + /// Vulnerable code is not reachable. + No, + + /// Reachability cannot be determined. + Unknown +} + +/// +/// VEX status per OpenVEX specification. +/// +public enum TriageVexStatus +{ + /// Product is affected by the vulnerability. + Affected, + + /// Product is not affected by the vulnerability. + NotAffected, + + /// Investigation is ongoing. + UnderInvestigation, + + /// Status is unknown. + Unknown +} + +/// +/// Type of triage decision. +/// +public enum TriageDecisionKind +{ + /// Mute based on reachability analysis. + MuteReach, + + /// Mute based on VEX status. + MuteVex, + + /// Acknowledge the finding without action. + Ack, + + /// Grant a security exception. + Exception +} + +/// +/// Trigger that caused a triage snapshot to be created. +/// +public enum TriageSnapshotTrigger +{ + /// Vulnerability feed was updated. + FeedUpdate, + + /// VEX document was updated. + VexUpdate, + + /// SBOM was updated. + SbomUpdate, + + /// Runtime trace was received. + RuntimeTrace, + + /// Policy was updated. + PolicyUpdate, + + /// A triage decision was made. + Decision, + + /// Manual rescan was triggered. + Rescan +} + +/// +/// Type of evidence artifact attached to a finding. +/// +public enum TriageEvidenceType +{ + /// Slice of the SBOM relevant to the finding. + SbomSlice, + + /// VEX document. + VexDoc, + + /// Build provenance attestation. + Provenance, + + /// Callstack or callgraph slice. + CallstackSlice, + + /// Reachability proof document. + ReachabilityProof, + + /// Replay manifest for deterministic reproduction. + ReplayManifest, + + /// Policy document that was applied. + Policy, + + /// Scan log output. + ScanLog, + + /// Other evidence type. + Other +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs new file mode 100644 index 00000000..15ee64a8 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs @@ -0,0 +1,103 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Evidence artifact attached to a finding. Hash-addressed and optionally signed. +/// +[Table("triage_evidence_artifact")] +public sealed class TriageEvidenceArtifact +{ + /// + /// Unique identifier. + /// + [Key] + [Column("id")] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// The finding this evidence applies to. + /// + [Column("finding_id")] + public Guid FindingId { get; init; } + + /// + /// Type of evidence. + /// + [Column("type")] + public TriageEvidenceType Type { get; init; } + + /// + /// Human-readable title for the evidence. + /// + [Required] + [Column("title")] + public required string Title { get; init; } + + /// + /// Issuer of the evidence (if applicable). + /// + [Column("issuer")] + public string? Issuer { get; init; } + + /// + /// Whether the evidence is cryptographically signed. + /// + [Column("signed")] + public bool Signed { get; init; } + + /// + /// Entity that signed the evidence. + /// + [Column("signed_by")] + public string? SignedBy { get; init; } + + /// + /// Content-addressable hash of the artifact. + /// + [Required] + [Column("content_hash")] + public required string ContentHash { get; init; } + + /// + /// Reference to the signature. + /// + [Column("signature_ref")] + public string? SignatureRef { get; init; } + + /// + /// MIME type of the artifact. + /// + [Column("media_type")] + public string? MediaType { get; init; } + + /// + /// URI to the artifact (object store, file path, or inline reference). + /// + [Required] + [Column("uri")] + public required string Uri { get; init; } + + /// + /// Size of the artifact in bytes. + /// + [Column("size_bytes")] + public long? SizeBytes { get; init; } + + /// + /// Additional metadata (JSON). + /// + [Column("metadata", TypeName = "jsonb")] + public string? MetadataJson { get; init; } + + /// + /// When this artifact was created. + /// + [Column("created_at")] + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + // Navigation property + [ForeignKey(nameof(FindingId))] + public TriageFinding? Finding { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs new file mode 100644 index 00000000..43ca820b --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Represents a triage finding (case). This is the core entity that ties +/// together all triage-related data for a specific vulnerability/rule +/// on a specific asset. +/// +[Table("triage_finding")] +public sealed class TriageFinding +{ + /// + /// Unique identifier for the finding (also serves as the case ID). + /// + [Key] + [Column("id")] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// The asset this finding applies to. + /// + [Column("asset_id")] + public Guid AssetId { get; init; } + + /// + /// Optional environment identifier (e.g., prod, staging). + /// + [Column("environment_id")] + public Guid? EnvironmentId { get; init; } + + /// + /// Human-readable asset label (e.g., "prod/api-gateway:1.2.3"). + /// + [Required] + [Column("asset_label")] + public required string AssetLabel { get; init; } + + /// + /// Package URL identifying the affected component. + /// + [Required] + [Column("purl")] + public required string Purl { get; init; } + + /// + /// CVE identifier if this is a vulnerability finding. + /// + [Column("cve_id")] + public string? CveId { get; init; } + + /// + /// Rule identifier if this is a policy rule finding. + /// + [Column("rule_id")] + public string? RuleId { get; init; } + + /// + /// When this finding was first observed. + /// + [Column("first_seen_at")] + public DateTimeOffset FirstSeenAt { get; init; } = DateTimeOffset.UtcNow; + + /// + /// When this finding was last observed. + /// + [Column("last_seen_at")] + public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; + + // Navigation properties + public ICollection EffectiveVexRecords { get; init; } = new List(); + public ICollection ReachabilityResults { get; init; } = new List(); + public ICollection RiskResults { get; init; } = new List(); + public ICollection Decisions { get; init; } = new List(); + public ICollection EvidenceArtifacts { get; init; } = new List(); + public ICollection Snapshots { get; init; } = new List(); +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs new file mode 100644 index 00000000..28bdd665 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Reachability analysis result for a finding. +/// +[Table("triage_reachability_result")] +public sealed class TriageReachabilityResult +{ + /// + /// Unique identifier. + /// + [Key] + [Column("id")] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// The finding this reachability result applies to. + /// + [Column("finding_id")] + public Guid FindingId { get; init; } + + /// + /// Reachability determination (Yes, No, Unknown). + /// + [Column("reachable")] + public TriageReachability Reachable { get; init; } + + /// + /// Confidence level (0-100). + /// + [Column("confidence")] + [Range(0, 100)] + public short Confidence { get; init; } + + /// + /// Reference to static analysis proof (callgraph slice, CFG slice). + /// + [Column("static_proof_ref")] + public string? StaticProofRef { get; init; } + + /// + /// Reference to runtime proof (runtime trace hits). + /// + [Column("runtime_proof_ref")] + public string? RuntimeProofRef { get; init; } + + /// + /// Hash of the inputs used to compute reachability (for caching/diffing). + /// + [Required] + [Column("inputs_hash")] + public required string InputsHash { get; init; } + + /// + /// When this result was computed. + /// + [Column("computed_at")] + public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow; + + // Navigation property + [ForeignKey(nameof(FindingId))] + public TriageFinding? Finding { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs new file mode 100644 index 00000000..80b2eafc --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs @@ -0,0 +1,87 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Risk/lattice result from the scanner's policy evaluation. +/// +[Table("triage_risk_result")] +public sealed class TriageRiskResult +{ + /// + /// Unique identifier. + /// + [Key] + [Column("id")] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// The finding this risk result applies to. + /// + [Column("finding_id")] + public Guid FindingId { get; init; } + + /// + /// The policy that was applied. + /// + [Required] + [Column("policy_id")] + public required string PolicyId { get; init; } + + /// + /// Version of the policy that was applied. + /// + [Required] + [Column("policy_version")] + public required string PolicyVersion { get; init; } + + /// + /// Hash of the inputs used for this evaluation. + /// + [Required] + [Column("inputs_hash")] + public required string InputsHash { get; init; } + + /// + /// Computed risk score (0-100). + /// + [Column("score")] + [Range(0, 100)] + public int Score { get; init; } + + /// + /// Final verdict (Ship, Block, Exception). + /// + [Column("verdict")] + public TriageVerdict Verdict { get; init; } + + /// + /// Current lane based on policy evaluation. + /// + [Column("lane")] + public TriageLane Lane { get; init; } + + /// + /// Short narrative explaining the decision. + /// + [Required] + [Column("why")] + public required string Why { get; init; } + + /// + /// Structured lattice explanation for UI diffing (JSON). + /// + [Column("explanation", TypeName = "jsonb")] + public string? ExplanationJson { get; init; } + + /// + /// When this result was computed. + /// + [Column("computed_at")] + public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow; + + // Navigation property + [ForeignKey(nameof(FindingId))] + public TriageFinding? Finding { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs new file mode 100644 index 00000000..af79a40a --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Scanner.Triage.Entities; + +/// +/// Immutable snapshot record for Smart-Diff, capturing input/output changes. +/// +[Table("triage_snapshot")] +public sealed class TriageSnapshot +{ + /// + /// Unique identifier. + /// + [Key] + [Column("id")] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// The finding this snapshot applies to. + /// + [Column("finding_id")] + public Guid FindingId { get; init; } + + /// + /// What triggered this snapshot. + /// + [Column("trigger")] + public TriageSnapshotTrigger Trigger { get; init; } + + /// + /// Previous inputs hash (null for first snapshot). + /// + [Column("from_inputs_hash")] + public string? FromInputsHash { get; init; } + + /// + /// New inputs hash. + /// + [Required] + [Column("to_inputs_hash")] + public required string ToInputsHash { get; init; } + + /// + /// Human-readable summary of what changed. + /// + [Required] + [Column("summary")] + public required string Summary { get; init; } + + /// + /// Precomputed diff in JSON format (optional). + /// + [Column("diff_json", TypeName = "jsonb")] + public string? DiffJson { get; init; } + + /// + /// When this snapshot was created. + /// + [Column("created_at")] + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + // Navigation property + [ForeignKey(nameof(FindingId))] + public TriageFinding? Finding { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Migrations/V3700_001__triage_schema.sql b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Migrations/V3700_001__triage_schema.sql new file mode 100644 index 00000000..aa52bbd8 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Migrations/V3700_001__triage_schema.sql @@ -0,0 +1,249 @@ +-- Stella Ops Triage Schema Migration +-- Generated from docs/db/triage_schema.sql +-- Version: 1.0.0 + +BEGIN; + +-- Extensions +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Enums +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_lane') THEN + CREATE TYPE triage_lane AS ENUM ( + 'ACTIVE', + 'BLOCKED', + 'NEEDS_EXCEPTION', + 'MUTED_REACH', + 'MUTED_VEX', + 'COMPENSATED' + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_verdict') THEN + CREATE TYPE triage_verdict AS ENUM ('SHIP', 'BLOCK', 'EXCEPTION'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_reachability') THEN + CREATE TYPE triage_reachability AS ENUM ('YES', 'NO', 'UNKNOWN'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_vex_status') THEN + CREATE TYPE triage_vex_status AS ENUM ('affected', 'not_affected', 'under_investigation', 'unknown'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_decision_kind') THEN + CREATE TYPE triage_decision_kind AS ENUM ('MUTE_REACH', 'MUTE_VEX', 'ACK', 'EXCEPTION'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_snapshot_trigger') THEN + CREATE TYPE triage_snapshot_trigger AS ENUM ( + 'FEED_UPDATE', + 'VEX_UPDATE', + 'SBOM_UPDATE', + 'RUNTIME_TRACE', + 'POLICY_UPDATE', + 'DECISION', + 'RESCAN' + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_evidence_type') THEN + CREATE TYPE triage_evidence_type AS ENUM ( + 'SBOM_SLICE', + 'VEX_DOC', + 'PROVENANCE', + 'CALLSTACK_SLICE', + 'REACHABILITY_PROOF', + 'REPLAY_MANIFEST', + 'POLICY', + 'SCAN_LOG', + 'OTHER' + ); + END IF; +END $$; + +-- Core: finding (caseId == findingId) +CREATE TABLE IF NOT EXISTS triage_finding ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL, + environment_id uuid NULL, + asset_label text NOT NULL, + purl text NOT NULL, + cve_id text NULL, + rule_id text NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (asset_id, environment_id, purl, cve_id, rule_id) +); + +CREATE INDEX IF NOT EXISTS ix_triage_finding_last_seen ON triage_finding (last_seen_at DESC); +CREATE INDEX IF NOT EXISTS ix_triage_finding_asset_label ON triage_finding (asset_label); +CREATE INDEX IF NOT EXISTS ix_triage_finding_purl ON triage_finding (purl); +CREATE INDEX IF NOT EXISTS ix_triage_finding_cve ON triage_finding (cve_id); + +-- Effective VEX (post-merge) +CREATE TABLE IF NOT EXISTS triage_effective_vex ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE, + status triage_vex_status NOT NULL, + source_domain text NOT NULL, + source_ref text NOT NULL, + pruned_sources jsonb NULL, + dsse_envelope_hash text NULL, + signature_ref text NULL, + issuer text NULL, + valid_from timestamptz NOT NULL DEFAULT now(), + valid_to timestamptz NULL, + collected_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_triage_effective_vex_finding ON triage_effective_vex (finding_id, collected_at DESC); + +-- Reachability results +CREATE TABLE IF NOT EXISTS triage_reachability_result ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE, + reachable triage_reachability NOT NULL, + confidence smallint NOT NULL CHECK (confidence >= 0 AND confidence <= 100), + static_proof_ref text NULL, + runtime_proof_ref text NULL, + inputs_hash text NOT NULL, + computed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_triage_reachability_finding ON triage_reachability_result (finding_id, computed_at DESC); + +-- Risk/lattice result +CREATE TABLE IF NOT EXISTS triage_risk_result ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE, + policy_id text NOT NULL, + policy_version text NOT NULL, + inputs_hash text NOT NULL, + score int NOT NULL CHECK (score >= 0 AND score <= 100), + verdict triage_verdict NOT NULL, + lane triage_lane NOT NULL, + why text NOT NULL, + explanation jsonb NULL, + computed_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (finding_id, policy_id, policy_version, inputs_hash) +); + +CREATE INDEX IF NOT EXISTS ix_triage_risk_finding ON triage_risk_result (finding_id, computed_at DESC); +CREATE INDEX IF NOT EXISTS ix_triage_risk_lane ON triage_risk_result (lane, computed_at DESC); + +-- Signed Decisions +CREATE TABLE IF NOT EXISTS triage_decision ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE, + kind triage_decision_kind NOT NULL, + reason_code text NOT NULL, + note text NULL, + policy_ref text NULL, + ttl timestamptz NULL, + actor_subject text NOT NULL, + actor_display text NULL, + signature_ref text NULL, + dsse_hash text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz NULL, + revoke_reason text NULL, + revoke_signature_ref text NULL, + revoke_dsse_hash text NULL +); + +CREATE INDEX IF NOT EXISTS ix_triage_decision_finding ON triage_decision (finding_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_triage_decision_kind ON triage_decision (kind, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_triage_decision_active ON triage_decision (finding_id) WHERE revoked_at IS NULL; + +-- Evidence artifacts +CREATE TABLE IF NOT EXISTS triage_evidence_artifact ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE, + type triage_evidence_type NOT NULL, + title text NOT NULL, + issuer text NULL, + signed boolean NOT NULL DEFAULT false, + signed_by text NULL, + content_hash text NOT NULL, + signature_ref text NULL, + media_type text NULL, + uri text NOT NULL, + size_bytes bigint NULL, + metadata jsonb NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (finding_id, type, content_hash) +); + +CREATE INDEX IF NOT EXISTS ix_triage_evidence_finding ON triage_evidence_artifact (finding_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_triage_evidence_type ON triage_evidence_artifact (type, created_at DESC); + +-- Snapshots for Smart-Diff +CREATE TABLE IF NOT EXISTS triage_snapshot ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE, + trigger triage_snapshot_trigger NOT NULL, + from_inputs_hash text NULL, + to_inputs_hash text NOT NULL, + summary text NOT NULL, + diff_json jsonb NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (finding_id, to_inputs_hash, created_at) +); + +CREATE INDEX IF NOT EXISTS ix_triage_snapshot_finding ON triage_snapshot (finding_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_triage_snapshot_trigger ON triage_snapshot (trigger, created_at DESC); + +-- Current-case view +CREATE OR REPLACE VIEW v_triage_case_current AS +WITH latest_risk AS ( + SELECT DISTINCT ON (finding_id) + finding_id, policy_id, policy_version, inputs_hash, score, verdict, lane, why, computed_at + FROM triage_risk_result + ORDER BY finding_id, computed_at DESC +), +latest_reach AS ( + SELECT DISTINCT ON (finding_id) + finding_id, reachable, confidence, static_proof_ref, runtime_proof_ref, computed_at + FROM triage_reachability_result + ORDER BY finding_id, computed_at DESC +), +latest_vex AS ( + SELECT DISTINCT ON (finding_id) + finding_id, status, issuer, signature_ref, source_domain, source_ref, collected_at + FROM triage_effective_vex + ORDER BY finding_id, collected_at DESC +) +SELECT + f.id AS case_id, + f.asset_id, + f.environment_id, + f.asset_label, + f.purl, + f.cve_id, + f.rule_id, + f.first_seen_at, + f.last_seen_at, + r.policy_id, + r.policy_version, + r.inputs_hash, + r.score, + r.verdict, + r.lane, + r.why, + r.computed_at AS risk_computed_at, + coalesce(re.reachable, 'UNKNOWN'::triage_reachability) AS reachable, + re.confidence AS reach_confidence, + v.status AS vex_status, + v.issuer AS vex_issuer, + v.signature_ref AS vex_signature_ref, + v.source_domain AS vex_source_domain, + v.source_ref AS vex_source_ref +FROM triage_finding f +LEFT JOIN latest_risk r ON r.finding_id = f.id +LEFT JOIN latest_reach re ON re.finding_id = f.id +LEFT JOIN latest_vex v ON v.finding_id = f.id; + +COMMIT; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/StellaOps.Scanner.Triage.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/StellaOps.Scanner.Triage.csproj new file mode 100644 index 00000000..90d3fcfa --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/StellaOps.Scanner.Triage.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + preview + enable + enable + false + StellaOps.Scanner.Triage + + + + + + + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/TriageDbContext.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/TriageDbContext.cs new file mode 100644 index 00000000..3529334a --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/TriageDbContext.cs @@ -0,0 +1,228 @@ +using Microsoft.EntityFrameworkCore; +using StellaOps.Scanner.Triage.Entities; + +namespace StellaOps.Scanner.Triage; + +/// +/// Entity Framework Core DbContext for the Triage schema. +/// +public sealed class TriageDbContext : DbContext +{ + /// + /// Initializes a new instance of the class. + /// + public TriageDbContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Triage findings (cases). + /// + public DbSet Findings => Set(); + + /// + /// Effective VEX records. + /// + public DbSet EffectiveVex => Set(); + + /// + /// Reachability analysis results. + /// + public DbSet ReachabilityResults => Set(); + + /// + /// Risk/lattice evaluation results. + /// + public DbSet RiskResults => Set(); + + /// + /// Triage decisions. + /// + public DbSet Decisions => Set(); + + /// + /// Evidence artifacts. + /// + public DbSet EvidenceArtifacts => Set(); + + /// + /// Snapshots for Smart-Diff. + /// + public DbSet Snapshots => Set(); + + /// + /// Current case view (read-only). + /// + public DbSet CurrentCases => Set(); + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure PostgreSQL enums + modelBuilder.HasPostgresEnum("triage_lane"); + modelBuilder.HasPostgresEnum("triage_verdict"); + modelBuilder.HasPostgresEnum("triage_reachability"); + modelBuilder.HasPostgresEnum("triage_vex_status"); + modelBuilder.HasPostgresEnum("triage_decision_kind"); + modelBuilder.HasPostgresEnum("triage_snapshot_trigger"); + modelBuilder.HasPostgresEnum("triage_evidence_type"); + + // Configure TriageFinding + modelBuilder.Entity(entity => + { + entity.ToTable("triage_finding"); + entity.HasKey(e => e.Id); + + entity.HasIndex(e => e.LastSeenAt) + .IsDescending() + .HasDatabaseName("ix_triage_finding_last_seen"); + + entity.HasIndex(e => e.AssetLabel) + .HasDatabaseName("ix_triage_finding_asset_label"); + + entity.HasIndex(e => e.Purl) + .HasDatabaseName("ix_triage_finding_purl"); + + entity.HasIndex(e => e.CveId) + .HasDatabaseName("ix_triage_finding_cve"); + + entity.HasIndex(e => new { e.AssetId, e.EnvironmentId, e.Purl, e.CveId, e.RuleId }) + .IsUnique(); + }); + + // Configure TriageEffectiveVex + modelBuilder.Entity(entity => + { + entity.ToTable("triage_effective_vex"); + entity.HasKey(e => e.Id); + + entity.HasIndex(e => new { e.FindingId, e.CollectedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_effective_vex_finding"); + + entity.HasOne(e => e.Finding) + .WithMany(f => f.EffectiveVexRecords) + .HasForeignKey(e => e.FindingId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure TriageReachabilityResult + modelBuilder.Entity(entity => + { + entity.ToTable("triage_reachability_result"); + entity.HasKey(e => e.Id); + + entity.HasIndex(e => new { e.FindingId, e.ComputedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_reachability_finding"); + + entity.HasOne(e => e.Finding) + .WithMany(f => f.ReachabilityResults) + .HasForeignKey(e => e.FindingId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure TriageRiskResult + modelBuilder.Entity(entity => + { + entity.ToTable("triage_risk_result"); + entity.HasKey(e => e.Id); + + entity.HasIndex(e => new { e.FindingId, e.ComputedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_risk_finding"); + + entity.HasIndex(e => new { e.Lane, e.ComputedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_risk_lane"); + + entity.HasIndex(e => new { e.FindingId, e.PolicyId, e.PolicyVersion, e.InputsHash }) + .IsUnique(); + + entity.HasOne(e => e.Finding) + .WithMany(f => f.RiskResults) + .HasForeignKey(e => e.FindingId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure TriageDecision + modelBuilder.Entity(entity => + { + entity.ToTable("triage_decision"); + entity.HasKey(e => e.Id); + + entity.HasIndex(e => new { e.FindingId, e.CreatedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_decision_finding"); + + entity.HasIndex(e => new { e.Kind, e.CreatedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_decision_kind"); + + entity.HasIndex(e => e.FindingId) + .HasFilter("revoked_at IS NULL") + .HasDatabaseName("ix_triage_decision_active"); + + entity.HasOne(e => e.Finding) + .WithMany(f => f.Decisions) + .HasForeignKey(e => e.FindingId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure TriageEvidenceArtifact + modelBuilder.Entity(entity => + { + entity.ToTable("triage_evidence_artifact"); + entity.HasKey(e => e.Id); + + entity.HasIndex(e => new { e.FindingId, e.CreatedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_evidence_finding"); + + entity.HasIndex(e => new { e.Type, e.CreatedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_evidence_type"); + + entity.HasIndex(e => new { e.FindingId, e.Type, e.ContentHash }) + .IsUnique(); + + entity.HasOne(e => e.Finding) + .WithMany(f => f.EvidenceArtifacts) + .HasForeignKey(e => e.FindingId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure TriageSnapshot + modelBuilder.Entity(entity => + { + entity.ToTable("triage_snapshot"); + entity.HasKey(e => e.Id); + + entity.HasIndex(e => new { e.FindingId, e.CreatedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_snapshot_finding"); + + entity.HasIndex(e => new { e.Trigger, e.CreatedAt }) + .IsDescending(false, true) + .HasDatabaseName("ix_triage_snapshot_trigger"); + + entity.HasIndex(e => new { e.FindingId, e.ToInputsHash, e.CreatedAt }) + .IsUnique(); + + entity.HasOne(e => e.Finding) + .WithMany(f => f.Snapshots) + .HasForeignKey(e => e.FindingId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure the read-only view + modelBuilder.Entity(entity => + { + entity.ToView("v_triage_case_current"); + entity.HasNoKey(); + }); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Index/OfflineBuildIdIndexTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Index/OfflineBuildIdIndexTests.cs new file mode 100644 index 00000000..51289bee --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Index/OfflineBuildIdIndexTests.cs @@ -0,0 +1,281 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.Native.Index.Tests; + +/// +/// Unit tests for . +/// +public sealed class OfflineBuildIdIndexTests : IDisposable +{ + private readonly string _tempDir; + + public OfflineBuildIdIndexTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"buildid-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + #region Loading Tests + + [Fact] + public async Task LoadAsync_EmptyIndex_WhenNoPathConfigured() + { + var options = Options.Create(new BuildIdIndexOptions { IndexPath = null }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + + await index.LoadAsync(); + + Assert.True(index.IsLoaded); + Assert.Equal(0, index.Count); + } + + [Fact] + public async Task LoadAsync_EmptyIndex_WhenFileNotFound() + { + var options = Options.Create(new BuildIdIndexOptions { IndexPath = "/nonexistent/file.ndjson" }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + + await index.LoadAsync(); + + Assert.True(index.IsLoaded); + Assert.Equal(0, index.Count); + } + + [Fact] + public async Task LoadAsync_ParsesNdjsonEntries() + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"} + {"build_id":"pe-cv:12345678-1234-1234-1234-123456789012-1","purl":"pkg:nuget/System.Text.Json@8.0.0","confidence":"inferred"} + {"build_id":"macho-uuid:fedcba9876543210fedcba9876543210","purl":"pkg:brew/openssl@3.0.0","distro":"macos","confidence":"exact"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + + await index.LoadAsync(); + + Assert.True(index.IsLoaded); + Assert.Equal(3, index.Count); + } + + [Fact] + public async Task LoadAsync_SkipsEmptyLines() + { + var indexPath = Path.Combine(_tempDir, "index-empty-lines.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} + + {"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"} + + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + + await index.LoadAsync(); + + Assert.Equal(2, index.Count); + } + + [Fact] + public async Task LoadAsync_SkipsCommentLines() + { + var indexPath = Path.Combine(_tempDir, "index-comments.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + # This is a comment + {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} + // Another comment style + {"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + + await index.LoadAsync(); + + Assert.Equal(2, index.Count); + } + + [Fact] + public async Task LoadAsync_SkipsInvalidJsonLines() + { + var indexPath = Path.Combine(_tempDir, "index-invalid.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} + not valid json at all + {"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + + await index.LoadAsync(); + + Assert.Equal(2, index.Count); + } + + #endregion + + #region Lookup Tests + + [Fact] + public async Task LookupAsync_ReturnsNull_WhenNotFound() + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + await index.LoadAsync(); + + var result = await index.LookupAsync("gnu-build-id:notfound"); + + Assert.Null(result); + } + + [Fact] + public async Task LookupAsync_ReturnsNull_ForNullOrEmpty() + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + await index.LoadAsync(); + + Assert.Null(await index.LookupAsync(null!)); + Assert.Null(await index.LookupAsync("")); + Assert.Null(await index.LookupAsync(" ")); + } + + [Fact] + public async Task LookupAsync_FindsExactMatch() + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:abc123def456","purl":"pkg:deb/debian/libc6@2.31","version":"2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + await index.LoadAsync(); + + var result = await index.LookupAsync("gnu-build-id:abc123def456"); + + Assert.NotNull(result); + Assert.Equal("gnu-build-id:abc123def456", result.BuildId); + Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl); + Assert.Equal("2.31", result.Version); + Assert.Equal("debian", result.SourceDistro); + Assert.Equal(BuildIdConfidence.Exact, result.Confidence); + } + + [Fact] + public async Task LookupAsync_CaseInsensitive() + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:ABC123DEF456","purl":"pkg:deb/debian/libc6@2.31"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + await index.LoadAsync(); + + // Query with lowercase + var result = await index.LookupAsync("gnu-build-id:abc123def456"); + + Assert.NotNull(result); + Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl); + } + + #endregion + + #region Batch Lookup Tests + + [Fact] + public async Task BatchLookupAsync_ReturnsFoundEntries() + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"} + {"build_id":"gnu-build-id:bbb","purl":"pkg:deb/debian/libb@1.0"} + {"build_id":"gnu-build-id:ccc","purl":"pkg:deb/debian/libc@1.0"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + await index.LoadAsync(); + + var results = await index.BatchLookupAsync(["gnu-build-id:aaa", "gnu-build-id:notfound", "gnu-build-id:ccc"]); + + Assert.Equal(2, results.Count); + Assert.Contains(results, r => r.Purl == "pkg:deb/debian/liba@1.0"); + Assert.Contains(results, r => r.Purl == "pkg:deb/debian/libc@1.0"); + } + + [Fact] + public async Task BatchLookupAsync_SkipsNullAndEmpty() + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + await File.WriteAllTextAsync(indexPath, """ + {"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"} + """); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + await index.LoadAsync(); + + var results = await index.BatchLookupAsync([null!, "", " ", "gnu-build-id:aaa"]); + + Assert.Single(results); + Assert.Equal("pkg:deb/debian/liba@1.0", results[0].Purl); + } + + #endregion + + #region Confidence Parsing Tests + + [Theory] + [InlineData("exact", BuildIdConfidence.Exact)] + [InlineData("EXACT", BuildIdConfidence.Exact)] + [InlineData("inferred", BuildIdConfidence.Inferred)] + [InlineData("Inferred", BuildIdConfidence.Inferred)] + [InlineData("heuristic", BuildIdConfidence.Heuristic)] + [InlineData("unknown", BuildIdConfidence.Heuristic)] // Defaults to heuristic + [InlineData("", BuildIdConfidence.Heuristic)] + public async Task LoadAsync_ParsesConfidenceLevels(string confidenceValue, BuildIdConfidence expected) + { + var indexPath = Path.Combine(_tempDir, "index.ndjson"); + var entry = new { build_id = "gnu-build-id:test", purl = "pkg:test/test@1.0", confidence = confidenceValue }; + await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(entry)); + + var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); + var index = new OfflineBuildIdIndex(options, NullLogger.Instance); + await index.LoadAsync(); + + var result = await index.LookupAsync("gnu-build-id:test"); + + Assert.NotNull(result); + Assert.Equal(expected, result.Confidence); + } + + #endregion +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/MachOReaderTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/MachOReaderTests.cs new file mode 100644 index 00000000..dd5f498f --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/MachOReaderTests.cs @@ -0,0 +1,425 @@ +using System.Buffers.Binary; +using System.Text; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.Native.Tests; + +/// +/// Unit tests for . +/// +public sealed class MachOReaderTests +{ + #region Test Data Builders + + /// + /// Builds a minimal 64-bit Mach-O binary for testing. + /// + private static byte[] BuildMachO64( + int cpuType = 0x0100000C, // arm64 + int cpuSubtype = 0, + byte[]? uuid = null, + MachOPlatform platform = MachOPlatform.MacOS, + uint minOs = 0x000E0000, // 14.0 + uint sdk = 0x000E0000) + { + var loadCommands = new List(); + + // Add LC_UUID if provided + if (uuid is { Length: 16 }) + { + var uuidCmd = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd, 0x1B); // LC_UUID + BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd.AsSpan(4), 24); // cmdsize + Array.Copy(uuid, 0, uuidCmd, 8, 16); + loadCommands.Add(uuidCmd); + } + + // Add LC_BUILD_VERSION + var buildVersionCmd = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd, 0x32); // LC_BUILD_VERSION + BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(4), 24); // cmdsize + BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(8), (uint)platform); + BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(12), minOs); + BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(16), sdk); + BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(20), 0); // ntools + loadCommands.Add(buildVersionCmd); + + var sizeOfCmds = loadCommands.Sum(c => c.Length); + + // Build header (32 bytes for 64-bit) + var header = new byte[32]; + BinaryPrimitives.WriteUInt32LittleEndian(header, 0xFEEDFACF); // MH_MAGIC_64 + BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(4), cpuType); + BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(8), cpuSubtype); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(12), 2); // MH_EXECUTE + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(16), (uint)loadCommands.Count); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(20), (uint)sizeOfCmds); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(24), 0); // flags + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(28), 0); // reserved + + // Combine + var result = new byte[32 + sizeOfCmds]; + Array.Copy(header, result, 32); + var offset = 32; + foreach (var cmd in loadCommands) + { + Array.Copy(cmd, 0, result, offset, cmd.Length); + offset += cmd.Length; + } + + return result; + } + + /// + /// Builds a minimal 32-bit Mach-O binary for testing. + /// + private static byte[] BuildMachO32( + int cpuType = 7, // x86 + int cpuSubtype = 0, + byte[]? uuid = null) + { + var loadCommands = new List(); + + // Add LC_UUID if provided + if (uuid is { Length: 16 }) + { + var uuidCmd = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd, 0x1B); // LC_UUID + BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd.AsSpan(4), 24); // cmdsize + Array.Copy(uuid, 0, uuidCmd, 8, 16); + loadCommands.Add(uuidCmd); + } + + var sizeOfCmds = loadCommands.Sum(c => c.Length); + + // Build header (28 bytes for 32-bit) + var header = new byte[28]; + BinaryPrimitives.WriteUInt32LittleEndian(header, 0xFEEDFACE); // MH_MAGIC + BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(4), cpuType); + BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(8), cpuSubtype); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(12), 2); // MH_EXECUTE + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(16), (uint)loadCommands.Count); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(20), (uint)sizeOfCmds); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(24), 0); // flags + + // Combine + var result = new byte[28 + sizeOfCmds]; + Array.Copy(header, result, 28); + var offset = 28; + foreach (var cmd in loadCommands) + { + Array.Copy(cmd, 0, result, offset, cmd.Length); + offset += cmd.Length; + } + + return result; + } + + /// + /// Builds a fat (universal) binary containing multiple slices. + /// + private static byte[] BuildFatBinary(params byte[][] slices) + { + // Fat header: magic (4) + nfat_arch (4) + // Fat arch entries: 20 bytes each (cputype, cpusubtype, offset, size, align) + var headerSize = 8 + (slices.Length * 20); + var alignedHeaderSize = (headerSize + 0xFFF) & ~0xFFF; // 4KB alignment + + var totalSize = alignedHeaderSize + slices.Sum(s => ((s.Length + 0xFFF) & ~0xFFF)); + var result = new byte[totalSize]; + + // Write fat header (big-endian) + BinaryPrimitives.WriteUInt32BigEndian(result, 0xCAFEBABE); // FAT_MAGIC + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(4), (uint)slices.Length); + + var currentOffset = alignedHeaderSize; + for (var i = 0; i < slices.Length; i++) + { + var slice = slices[i]; + var archOffset = 8 + (i * 20); + + // Read CPU type from slice header + var cpuType = BinaryPrimitives.ReadUInt32LittleEndian(slice.AsSpan(4)); + + // Write fat_arch entry (big-endian) + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset), cpuType); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 4), 0); // cpusubtype + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 8), (uint)currentOffset); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 12), (uint)slice.Length); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 16), 12); // align = 2^12 = 4096 + + // Copy slice + Array.Copy(slice, 0, result, currentOffset, slice.Length); + currentOffset += (slice.Length + 0xFFF) & ~0xFFF; // Align to 4KB + } + + return result; + } + + #endregion + + #region Magic Detection Tests + + [Fact] + public void Parse_Returns_Null_For_Empty_Stream() + { + using var stream = new MemoryStream([]); + var result = MachOReader.Parse(stream, "/test/empty"); + Assert.Null(result); + } + + [Fact] + public void Parse_Returns_Null_For_Invalid_Magic() + { + var data = new byte[] { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77 }; + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/invalid"); + Assert.Null(result); + } + + [Fact] + public void Parse_Detects_64Bit_LittleEndian_MachO() + { + var data = BuildMachO64(); + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/arm64"); + + Assert.NotNull(result); + Assert.Single(result.Identities); + Assert.Equal("arm64", result.Identities[0].CpuType); + Assert.False(result.Identities[0].IsFatBinary); + } + + [Fact] + public void Parse_Detects_32Bit_MachO() + { + var data = BuildMachO32(cpuType: 7); // x86 + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/i386"); + + Assert.NotNull(result); + Assert.Single(result.Identities); + Assert.Equal("i386", result.Identities[0].CpuType); + } + + #endregion + + #region LC_UUID Tests + + [Fact] + public void Parse_Extracts_LC_UUID() + { + var uuid = new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10 }; + var data = BuildMachO64(uuid: uuid); + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/with-uuid"); + + Assert.NotNull(result); + Assert.Single(result.Identities); + Assert.Equal("0123456789abcdeffedcba9876543210", result.Identities[0].Uuid); + } + + [Fact] + public void Parse_Returns_Null_Uuid_When_Not_Present() + { + var data = BuildMachO64(uuid: null); + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/no-uuid"); + + Assert.NotNull(result); + Assert.Single(result.Identities); + Assert.Null(result.Identities[0].Uuid); + } + + [Fact] + public void Parse_UUID_Is_Lowercase_Hex_No_Dashes() + { + var uuid = new byte[] { 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x12, 0x34, 0x56, 0x78, 0x9A }; + var data = BuildMachO64(uuid: uuid); + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/uuid-format"); + + Assert.NotNull(result); + var uuidString = result.Identities[0].Uuid; + Assert.NotNull(uuidString); + Assert.Equal(32, uuidString.Length); + Assert.DoesNotContain("-", uuidString); + Assert.Equal(uuidString.ToLowerInvariant(), uuidString); + } + + #endregion + + #region Platform Detection Tests + + [Theory] + [InlineData(MachOPlatform.MacOS)] + [InlineData(MachOPlatform.iOS)] + [InlineData(MachOPlatform.TvOS)] + [InlineData(MachOPlatform.WatchOS)] + [InlineData(MachOPlatform.MacCatalyst)] + [InlineData(MachOPlatform.VisionOS)] + public void Parse_Extracts_Platform_From_LC_BUILD_VERSION(MachOPlatform expectedPlatform) + { + var data = BuildMachO64(platform: expectedPlatform); + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/platform"); + + Assert.NotNull(result); + Assert.Single(result.Identities); + Assert.Equal(expectedPlatform, result.Identities[0].Platform); + } + + [Fact] + public void Parse_Extracts_MinOs_Version() + { + var data = BuildMachO64(minOs: 0x000E0500); // 14.5.0 + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/min-os"); + + Assert.NotNull(result); + Assert.Equal("14.5", result.Identities[0].MinOsVersion); + } + + [Fact] + public void Parse_Extracts_SDK_Version() + { + var data = BuildMachO64(sdk: 0x000F0000); // 15.0.0 + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/sdk"); + + Assert.NotNull(result); + Assert.Equal("15.0", result.Identities[0].SdkVersion); + } + + [Fact] + public void Parse_Version_With_Patch() + { + var data = BuildMachO64(minOs: 0x000E0501); // 14.5.1 + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/version-patch"); + + Assert.NotNull(result); + Assert.Equal("14.5.1", result.Identities[0].MinOsVersion); + } + + #endregion + + #region CPU Type Tests + + [Theory] + [InlineData(0x00000007, "i386")] // CPU_TYPE_X86 + [InlineData(0x01000007, "x86_64")] // CPU_TYPE_X86_64 + [InlineData(0x0000000C, "arm")] // CPU_TYPE_ARM + [InlineData(0x0100000C, "arm64")] // CPU_TYPE_ARM64 + public void Parse_Maps_CpuType_Correctly(int cpuType, string expectedName) + { + var data = BuildMachO64(cpuType: cpuType); + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/test/cpu"); + + Assert.NotNull(result); + Assert.Equal(expectedName, result.Identities[0].CpuType); + } + + #endregion + + #region Fat Binary Tests + + [Fact] + public void Parse_Handles_Fat_Binary() + { + var arm64Slice = BuildMachO64(cpuType: 0x0100000C, uuid: new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10 }); + var x64Slice = BuildMachO64(cpuType: 0x01000007, uuid: new byte[] { 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20 }); + + var fatData = BuildFatBinary(arm64Slice, x64Slice); + using var stream = new MemoryStream(fatData); + var result = MachOReader.Parse(stream, "/test/universal"); + + Assert.NotNull(result); + Assert.Equal(2, result.Identities.Count); + + // Both slices should be marked as fat binary slices + Assert.True(result.Identities[0].IsFatBinary); + Assert.True(result.Identities[1].IsFatBinary); + + // Check UUIDs are different + Assert.NotEqual(result.Identities[0].Uuid, result.Identities[1].Uuid); + } + + [Fact] + public void ParseFatBinary_Returns_Multiple_Identities() + { + var arm64Slice = BuildMachO64(cpuType: 0x0100000C); + var x64Slice = BuildMachO64(cpuType: 0x01000007); + + var fatData = BuildFatBinary(arm64Slice, x64Slice); + using var stream = new MemoryStream(fatData); + var identities = MachOReader.ParseFatBinary(stream); + + Assert.Equal(2, identities.Count); + } + + #endregion + + #region TryExtractIdentity Tests + + [Fact] + public void TryExtractIdentity_Returns_True_For_Valid_MachO() + { + var data = BuildMachO64(); + using var stream = new MemoryStream(data); + + var success = MachOReader.TryExtractIdentity(stream, out var identity); + + Assert.True(success); + Assert.NotNull(identity); + Assert.Equal("arm64", identity.CpuType); + } + + [Fact] + public void TryExtractIdentity_Returns_False_For_Invalid_Data() + { + var data = new byte[] { 0x00, 0x00, 0x00, 0x00 }; + using var stream = new MemoryStream(data); + + var success = MachOReader.TryExtractIdentity(stream, out var identity); + + Assert.False(success); + Assert.Null(identity); + } + + [Fact] + public void TryExtractIdentity_Returns_First_Slice_For_Fat_Binary() + { + var arm64Slice = BuildMachO64(cpuType: 0x0100000C); + var x64Slice = BuildMachO64(cpuType: 0x01000007); + + var fatData = BuildFatBinary(arm64Slice, x64Slice); + using var stream = new MemoryStream(fatData); + + var success = MachOReader.TryExtractIdentity(stream, out var identity); + + Assert.True(success); + Assert.NotNull(identity); + // Should get first slice + Assert.Equal("arm64", identity.CpuType); + } + + #endregion + + #region Path and LayerDigest Tests + + [Fact] + public void Parse_Preserves_Path_And_LayerDigest() + { + var data = BuildMachO64(); + using var stream = new MemoryStream(data); + var result = MachOReader.Parse(stream, "/usr/bin/myapp", "sha256:abc123"); + + Assert.NotNull(result); + Assert.Equal("/usr/bin/myapp", result.Path); + Assert.Equal("sha256:abc123", result.LayerDigest); + } + + #endregion +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/PeReaderTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/PeReaderTests.cs new file mode 100644 index 00000000..ab382e6f --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/PeReaderTests.cs @@ -0,0 +1,361 @@ +using FluentAssertions; +using StellaOps.Scanner.Analyzers.Native; +using StellaOps.Scanner.Analyzers.Native.Tests.Fixtures; +using StellaOps.Scanner.Analyzers.Native.Tests.TestUtilities; + +namespace StellaOps.Scanner.Analyzers.Native.Tests; + +/// +/// Unit tests for PeReader full PE parsing including CodeView GUID, Rich header, and version resources. +/// +public class PeReaderTests : NativeTestBase +{ + #region Basic Parsing + + [Fact] + public void TryExtractIdentity_InvalidData_ReturnsFalse() + { + // Arrange + var invalidData = new byte[] { 0x00, 0x01, 0x02, 0x03 }; + + // Act + var result = PeReader.TryExtractIdentity(invalidData, out var identity); + + // Assert + result.Should().BeFalse(); + identity.Should().BeNull(); + } + + [Fact] + public void TryExtractIdentity_TooShort_ReturnsFalse() + { + // Arrange + var shortData = new byte[0x20]; + + // Act + var result = PeReader.TryExtractIdentity(shortData, out var identity); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void TryExtractIdentity_MissingMzSignature_ReturnsFalse() + { + // Arrange + var data = new byte[0x100]; + data[0] = (byte)'X'; + data[1] = (byte)'Y'; + + // Act + var result = PeReader.TryExtractIdentity(data, out var identity); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void TryExtractIdentity_ValidMinimalPe64_ReturnsTrue() + { + // Arrange + var pe = PeBuilder.Console64().Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.Is64Bit.Should().BeTrue(); + identity.Machine.Should().Be("x86_64"); + identity.Subsystem.Should().Be(PeSubsystem.WindowsConsole); + } + + [Fact] + public void TryExtractIdentity_ValidMinimalPe32_ReturnsTrue() + { + // Arrange + var pe = new PeBuilder() + .Is64Bit(false) + .WithSubsystem(PeSubsystem.WindowsConsole) + .Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.Is64Bit.Should().BeFalse(); + identity.Machine.Should().Be("x86"); + } + + [Fact] + public void TryExtractIdentity_GuiSubsystem_ParsesCorrectly() + { + // Arrange + var pe = new PeBuilder() + .Is64Bit(true) + .WithSubsystem(PeSubsystem.WindowsGui) + .Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.Subsystem.Should().Be(PeSubsystem.WindowsGui); + } + + #endregion + + #region Parse Method + + [Fact] + public void Parse_ValidPeStream_ReturnsPeParseResult() + { + // Arrange + var pe = PeBuilder.Console64().Build(); + using var stream = new MemoryStream(pe); + + // Act + var result = PeReader.Parse(stream, "test.exe"); + + // Assert + result.Should().NotBeNull(); + result!.Identity.Should().NotBeNull(); + result.Identity.Is64Bit.Should().BeTrue(); + } + + [Fact] + public void Parse_InvalidStream_ReturnsNull() + { + // Arrange + var invalidData = new byte[] { 0x00, 0x01, 0x02, 0x03 }; + using var stream = new MemoryStream(invalidData); + + // Act + var result = PeReader.Parse(stream, "invalid.exe"); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Parse_ThrowsOnNullStream() + { + // Act & Assert + var action = () => PeReader.Parse(null!, "test.exe"); + action.Should().Throw(); + } + + #endregion + + #region Machine Architecture + + [Theory] + [InlineData(PeMachine.I386, "x86", false)] + [InlineData(PeMachine.Amd64, "x86_64", true)] + [InlineData(PeMachine.Arm64, "arm64", true)] + public void TryExtractIdentity_MachineTypes_MapCorrectly(PeMachine machine, string expectedArch, bool is64Bit) + { + // Arrange + var pe = new PeBuilder() + .Is64Bit(is64Bit) + .WithMachine(machine) + .Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.Machine.Should().Be(expectedArch); + } + + #endregion + + #region Exports + + [Fact] + public void TryExtractIdentity_NoExports_ReturnsEmptyList() + { + // Arrange - standard console app has no exports + var pe = PeBuilder.Console64().Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.Exports.Should().BeEmpty(); + } + + #endregion + + #region Compiler Hints (Rich Header) + + [Fact] + public void TryExtractIdentity_NoRichHeader_ReturnsEmptyHints() + { + // Arrange - builder-generated PEs don't have rich header + var pe = PeBuilder.Console64().Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.CompilerHints.Should().BeEmpty(); + identity.RichHeaderHash.Should().BeNull(); + } + + #endregion + + #region CodeView Debug Info + + [Fact] + public void TryExtractIdentity_NoDebugDirectory_ReturnsNullCodeView() + { + // Arrange - builder-generated PEs don't have debug directory + var pe = PeBuilder.Console64().Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.CodeViewGuid.Should().BeNull(); + identity.CodeViewAge.Should().BeNull(); + identity.PdbPath.Should().BeNull(); + } + + #endregion + + #region Version Resources + + [Fact] + public void TryExtractIdentity_NoVersionResource_ReturnsNullVersions() + { + // Arrange - builder-generated PEs don't have version resources + var pe = PeBuilder.Console64().Build(); + + // Act + var result = PeReader.TryExtractIdentity(pe, out var identity); + + // Assert + result.Should().BeTrue(); + identity.Should().NotBeNull(); + identity!.ProductVersion.Should().BeNull(); + identity.FileVersion.Should().BeNull(); + identity.CompanyName.Should().BeNull(); + identity.ProductName.Should().BeNull(); + identity.OriginalFilename.Should().BeNull(); + } + + #endregion + + #region Determinism + + [Fact] + public void TryExtractIdentity_SameInput_ReturnsSameOutput() + { + // Arrange + var pe = PeBuilder.Console64().Build(); + + // Act + PeReader.TryExtractIdentity(pe, out var identity1); + PeReader.TryExtractIdentity(pe, out var identity2); + + // Assert + identity1.Should().BeEquivalentTo(identity2); + } + + [Fact] + public void TryExtractIdentity_DifferentInputs_ReturnsDifferentOutput() + { + // Arrange + var pe64 = PeBuilder.Console64().Build(); + var pe32 = new PeBuilder().Is64Bit(false).Build(); + + // Act + PeReader.TryExtractIdentity(pe64, out var identity64); + PeReader.TryExtractIdentity(pe32, out var identity32); + + // Assert + identity64!.Is64Bit.Should().NotBe(identity32!.Is64Bit); + } + + #endregion + + #region Edge Cases + + [Fact] + public void TryExtractIdentity_InvalidPeOffset_ReturnsFalse() + { + // Arrange - Create data with MZ signature but invalid PE offset + var data = new byte[0x100]; + data[0] = (byte)'M'; + data[1] = (byte)'Z'; + // Set PE offset beyond file bounds + data[0x3C] = 0xFF; + data[0x3D] = 0xFF; + data[0x3E] = 0x00; + data[0x3F] = 0x00; + + // Act + var result = PeReader.TryExtractIdentity(data, out var identity); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void TryExtractIdentity_MissingPeSignature_ReturnsFalse() + { + // Arrange - Create data with MZ but missing PE signature + var data = new byte[0x100]; + data[0] = (byte)'M'; + data[1] = (byte)'Z'; + data[0x3C] = 0x80; // PE offset at 0x80 + // No PE signature at offset 0x80 + + // Act + var result = PeReader.TryExtractIdentity(data, out var identity); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void TryExtractIdentity_InvalidMagic_ReturnsFalse() + { + // Arrange - Create data with PE signature but invalid magic + var data = new byte[0x200]; + data[0] = (byte)'M'; + data[1] = (byte)'Z'; + data[0x3C] = 0x80; // PE offset at 0x80 + + // PE signature + data[0x80] = (byte)'P'; + data[0x81] = (byte)'E'; + data[0x82] = 0; + data[0x83] = 0; + + // Invalid COFF header with size 0 + data[0x80 + 16] = 0; // SizeOfOptionalHeader = 0 + + // Act + var result = PeReader.TryExtractIdentity(data, out var identity); + + // Assert + result.Should().BeFalse(); + } + + #endregion +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/PathWitnessBuilderTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/PathWitnessBuilderTests.cs new file mode 100644 index 00000000..82d40dc7 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/PathWitnessBuilderTests.cs @@ -0,0 +1,387 @@ +using StellaOps.Cryptography; +using StellaOps.Scanner.Reachability.Gates; +using StellaOps.Scanner.Reachability.Witnesses; +using Xunit; + +namespace StellaOps.Scanner.Reachability.Tests; + +public class PathWitnessBuilderTests +{ + private readonly ICryptoHash _cryptoHash; + private readonly TimeProvider _timeProvider; + + public PathWitnessBuilderTests() + { + _cryptoHash = DefaultCryptoHash.CreateForTests(); + _timeProvider = TimeProvider.System; + } + + [Fact] + public async Task BuildAsync_ReturnsNull_WhenNoPathExists() + { + // Arrange + var graph = CreateSimpleGraph(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new PathWitnessRequest + { + SbomDigest = "sha256:abc123", + ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3", + VulnId = "CVE-2024-12345", + VulnSource = "NVD", + AffectedRange = "<=12.0.3", + EntrypointSymbolId = "sym:entry1", + EntrypointKind = "http", + EntrypointName = "GET /api/test", + SinkSymbolId = "sym:unreachable", // Not in graph + SinkType = "deserialization", + CallGraph = graph, + CallgraphDigest = "blake3:abc123" + }; + + // Act + var result = await builder.BuildAsync(request); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task BuildAsync_ReturnsWitness_WhenPathExists() + { + // Arrange + var graph = CreateSimpleGraph(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new PathWitnessRequest + { + SbomDigest = "sha256:abc123", + ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3", + VulnId = "CVE-2024-12345", + VulnSource = "NVD", + AffectedRange = "<=12.0.3", + EntrypointSymbolId = "sym:entry1", + EntrypointKind = "http", + EntrypointName = "GET /api/test", + SinkSymbolId = "sym:sink1", + SinkType = "deserialization", + CallGraph = graph, + CallgraphDigest = "blake3:abc123" + }; + + // Act + var result = await builder.BuildAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(WitnessSchema.Version, result.WitnessSchema); + Assert.StartsWith(WitnessSchema.WitnessIdPrefix, result.WitnessId); + Assert.Equal("CVE-2024-12345", result.Vuln.Id); + Assert.Equal("sym:entry1", result.Entrypoint.SymbolId); + Assert.Equal("sym:sink1", result.Sink.SymbolId); + Assert.NotEmpty(result.Path); + } + + [Fact] + public async Task BuildAsync_GeneratesContentAddressedWitnessId() + { + // Arrange + var graph = CreateSimpleGraph(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new PathWitnessRequest + { + SbomDigest = "sha256:abc123", + ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3", + VulnId = "CVE-2024-12345", + VulnSource = "NVD", + AffectedRange = "<=12.0.3", + EntrypointSymbolId = "sym:entry1", + EntrypointKind = "http", + EntrypointName = "GET /api/test", + SinkSymbolId = "sym:sink1", + SinkType = "deserialization", + CallGraph = graph, + CallgraphDigest = "blake3:abc123" + }; + + // Act + var result1 = await builder.BuildAsync(request); + var result2 = await builder.BuildAsync(request); + + // Assert + Assert.NotNull(result1); + Assert.NotNull(result2); + // The witness ID should be deterministic (same input = same hash) + // Note: ObservedAt differs, but witness ID is computed without it + Assert.Equal(result1.WitnessId, result2.WitnessId); + } + + [Fact] + public async Task BuildAsync_PopulatesArtifactInfo() + { + // Arrange + var graph = CreateSimpleGraph(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new PathWitnessRequest + { + SbomDigest = "sha256:sbom123", + ComponentPurl = "pkg:npm/lodash@4.17.21", + VulnId = "CVE-2024-99999", + VulnSource = "GHSA", + AffectedRange = "<4.17.21", + EntrypointSymbolId = "sym:entry1", + EntrypointKind = "grpc", + EntrypointName = "UserService.GetUser", + SinkSymbolId = "sym:sink1", + SinkType = "prototype_pollution", + CallGraph = graph, + CallgraphDigest = "blake3:graph456" + }; + + // Act + var result = await builder.BuildAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal("sha256:sbom123", result.Artifact.SbomDigest); + Assert.Equal("pkg:npm/lodash@4.17.21", result.Artifact.ComponentPurl); + } + + [Fact] + public async Task BuildAsync_PopulatesEvidenceInfo() + { + // Arrange + var graph = CreateSimpleGraph(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new PathWitnessRequest + { + SbomDigest = "sha256:abc123", + ComponentPurl = "pkg:nuget/Test@1.0.0", + VulnId = "CVE-2024-12345", + VulnSource = "NVD", + AffectedRange = "<=1.0.0", + EntrypointSymbolId = "sym:entry1", + EntrypointKind = "http", + EntrypointName = "TestController.Get", + SinkSymbolId = "sym:sink1", + SinkType = "sql_injection", + CallGraph = graph, + CallgraphDigest = "blake3:callgraph789", + SurfaceDigest = "sha256:surface123", + AnalysisConfigDigest = "sha256:config456", + BuildId = "build:xyz789" + }; + + // Act + var result = await builder.BuildAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal("blake3:callgraph789", result.Evidence.CallgraphDigest); + Assert.Equal("sha256:surface123", result.Evidence.SurfaceDigest); + Assert.Equal("sha256:config456", result.Evidence.AnalysisConfigDigest); + Assert.Equal("build:xyz789", result.Evidence.BuildId); + } + + [Fact] + public async Task BuildAsync_FindsShortestPath() + { + // Arrange - graph with multiple paths + var graph = CreateGraphWithMultiplePaths(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new PathWitnessRequest + { + SbomDigest = "sha256:abc123", + ComponentPurl = "pkg:nuget/Test@1.0.0", + VulnId = "CVE-2024-12345", + VulnSource = "NVD", + AffectedRange = "<=1.0.0", + EntrypointSymbolId = "sym:start", + EntrypointKind = "http", + EntrypointName = "Start", + SinkSymbolId = "sym:end", + SinkType = "deserialization", + CallGraph = graph, + CallgraphDigest = "blake3:abc123" + }; + + // Act + var result = await builder.BuildAsync(request); + + // Assert + Assert.NotNull(result); + // Short path: start -> direct -> end (3 steps) + // Long path: start -> long1 -> long2 -> long3 -> end (5 steps) + Assert.Equal(3, result.Path.Count); + Assert.Equal("sym:start", result.Path[0].SymbolId); + Assert.Equal("sym:direct", result.Path[1].SymbolId); + Assert.Equal("sym:end", result.Path[2].SymbolId); + } + + [Fact] + public async Task BuildAllAsync_YieldsMultipleWitnesses_WhenMultipleRootsReachSink() + { + // Arrange + var graph = CreateGraphWithMultipleRoots(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new BatchWitnessRequest + { + SbomDigest = "sha256:abc123", + ComponentPurl = "pkg:nuget/Test@1.0.0", + VulnId = "CVE-2024-12345", + VulnSource = "NVD", + AffectedRange = "<=1.0.0", + SinkSymbolId = "sym:sink", + SinkType = "deserialization", + CallGraph = graph, + CallgraphDigest = "blake3:abc123", + MaxWitnesses = 10 + }; + + // Act + var witnesses = new List(); + await foreach (var witness in builder.BuildAllAsync(request)) + { + witnesses.Add(witness); + } + + // Assert + Assert.Equal(2, witnesses.Count); + Assert.Contains(witnesses, w => w.Entrypoint.SymbolId == "sym:root1"); + Assert.Contains(witnesses, w => w.Entrypoint.SymbolId == "sym:root2"); + } + + [Fact] + public async Task BuildAllAsync_RespectsMaxWitnesses() + { + // Arrange + var graph = CreateGraphWithMultipleRoots(); + var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); + + var request = new BatchWitnessRequest + { + SbomDigest = "sha256:abc123", + ComponentPurl = "pkg:nuget/Test@1.0.0", + VulnId = "CVE-2024-12345", + VulnSource = "NVD", + AffectedRange = "<=1.0.0", + SinkSymbolId = "sym:sink", + SinkType = "deserialization", + CallGraph = graph, + CallgraphDigest = "blake3:abc123", + MaxWitnesses = 1 // Limit to 1 + }; + + // Act + var witnesses = new List(); + await foreach (var witness in builder.BuildAllAsync(request)) + { + witnesses.Add(witness); + } + + // Assert + Assert.Single(witnesses); + } + + #region Test Helpers + + private static RichGraph CreateSimpleGraph() + { + var nodes = new List + { + new("n1", "sym:entry1", null, null, "dotnet", "method", "Entry1", null, null, null, null), + new("n2", "sym:middle1", null, null, "dotnet", "method", "Middle1", null, null, null, null), + new("n3", "sym:sink1", null, null, "dotnet", "method", "Sink1", null, null, null, null) + }; + + var edges = new List + { + new("n1", "n2", "call", null, null, null, 1.0, null), + new("n2", "n3", "call", null, null, null, 1.0, null) + }; + + var roots = new List + { + new("n1", "http", "/api/test") + }; + + return new RichGraph( + nodes, + edges, + roots, + new RichGraphAnalyzer("test", "1.0.0", null)); + } + + private static RichGraph CreateGraphWithMultiplePaths() + { + var nodes = new List + { + new("n0", "sym:start", null, null, "dotnet", "method", "Start", null, null, null, null), + new("n1", "sym:direct", null, null, "dotnet", "method", "Direct", null, null, null, null), + new("n2", "sym:long1", null, null, "dotnet", "method", "Long1", null, null, null, null), + new("n3", "sym:long2", null, null, "dotnet", "method", "Long2", null, null, null, null), + new("n4", "sym:long3", null, null, "dotnet", "method", "Long3", null, null, null, null), + new("n5", "sym:end", null, null, "dotnet", "method", "End", null, null, null, null) + }; + + var edges = new List + { + // Short path: start -> direct -> end + new("n0", "n1", "call", null, null, null, 1.0, null), + new("n1", "n5", "call", null, null, null, 1.0, null), + // Long path: start -> long1 -> long2 -> long3 -> end + new("n0", "n2", "call", null, null, null, 1.0, null), + new("n2", "n3", "call", null, null, null, 1.0, null), + new("n3", "n4", "call", null, null, null, 1.0, null), + new("n4", "n5", "call", null, null, null, 1.0, null) + }; + + var roots = new List + { + new("n0", "http", "/api/start") + }; + + return new RichGraph( + nodes, + edges, + roots, + new RichGraphAnalyzer("test", "1.0.0", null)); + } + + private static RichGraph CreateGraphWithMultipleRoots() + { + var nodes = new List + { + new("n1", "sym:root1", null, null, "dotnet", "method", "Root1", null, null, null, null), + new("n2", "sym:root2", null, null, "dotnet", "method", "Root2", null, null, null, null), + new("n3", "sym:middle", null, null, "dotnet", "method", "Middle", null, null, null, null), + new("n4", "sym:sink", null, null, "dotnet", "method", "Sink", null, null, null, null) + }; + + var edges = new List + { + new("n1", "n3", "call", null, null, null, 1.0, null), + new("n2", "n3", "call", null, null, null, 1.0, null), + new("n3", "n4", "call", null, null, null, 1.0, null) + }; + + var roots = new List + { + new("n1", "http", "/api/root1"), + new("n2", "http", "/api/root2") + }; + + return new RichGraph( + nodes, + edges, + roots, + new RichGraphAnalyzer("test", "1.0.0", null)); + } + + #endregion +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/ReachabilityWitnessDsseBuilderTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/ReachabilityWitnessDsseBuilderTests.cs new file mode 100644 index 00000000..74103138 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/ReachabilityWitnessDsseBuilderTests.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using StellaOps.Cryptography; +using StellaOps.Scanner.Reachability.Attestation; +using Xunit; + +namespace StellaOps.Scanner.Reachability.Tests; + +/// +/// Unit tests for . +/// Sprint: SPRINT_3620_0001_0001 +/// Task: RWD-011 +/// +public sealed class ReachabilityWitnessDsseBuilderTests +{ + private readonly ReachabilityWitnessDsseBuilder _builder; + private readonly FakeTimeProvider _timeProvider; + + public ReachabilityWitnessDsseBuilderTests() + { + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 18, 10, 0, 0, TimeSpan.Zero)); + _builder = new ReachabilityWitnessDsseBuilder( + CryptoHashFactory.CreateDefault(), + _timeProvider); + } + + #region BuildStatement Tests + + [Fact] + public void BuildStatement_CreatesValidStatement() + { + var graph = CreateTestGraph(); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + Assert.NotNull(statement); + Assert.Equal("https://in-toto.io/Statement/v1", statement.Type); + Assert.Equal("https://stella.ops/reachabilityWitness/v1", statement.PredicateType); + Assert.Single(statement.Subject); + } + + [Fact] + public void BuildStatement_SetsSubjectCorrectly() + { + var graph = CreateTestGraph(); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:imageabc123"); + + var subject = statement.Subject[0]; + Assert.Equal("sha256:imageabc123", subject.Name); + Assert.Equal("imageabc123", subject.Digest["sha256"]); + } + + [Fact] + public void BuildStatement_ExtractsPredicateCorrectly() + { + var graph = CreateTestGraph(); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456", + graphCasUri: "cas://local/blake3:abc123", + policyHash: "sha256:policy123", + sourceCommit: "abc123def456"); + + var predicate = statement.Predicate as ReachabilityWitnessStatement; + Assert.NotNull(predicate); + Assert.Equal("stella.ops/reachabilityWitness@v1", predicate.Schema); + Assert.Equal("blake3:abc123", predicate.GraphHash); + Assert.Equal("cas://local/blake3:abc123", predicate.GraphCasUri); + Assert.Equal("sha256:def456", predicate.SubjectDigest); + Assert.Equal("sha256:policy123", predicate.PolicyHash); + Assert.Equal("abc123def456", predicate.SourceCommit); + } + + [Fact] + public void BuildStatement_CountsNodesAndEdges() + { + var graph = CreateTestGraph(); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + var predicate = statement.Predicate as ReachabilityWitnessStatement; + Assert.NotNull(predicate); + Assert.Equal(3, predicate.NodeCount); + Assert.Equal(2, predicate.EdgeCount); + } + + [Fact] + public void BuildStatement_CountsEntrypoints() + { + var graph = CreateTestGraphWithRoots(); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + var predicate = statement.Predicate as ReachabilityWitnessStatement; + Assert.NotNull(predicate); + Assert.Equal(2, predicate.EntrypointCount); + } + + [Fact] + public void BuildStatement_UsesProvidedTimestamp() + { + var graph = CreateTestGraph(); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + var predicate = statement.Predicate as ReachabilityWitnessStatement; + Assert.NotNull(predicate); + Assert.Equal(_timeProvider.GetUtcNow(), predicate.GeneratedAt); + } + + [Fact] + public void BuildStatement_ExtractsAnalyzerVersion() + { + var graph = CreateTestGraph(); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + var predicate = statement.Predicate as ReachabilityWitnessStatement; + Assert.NotNull(predicate); + Assert.Equal("1.0.0", predicate.AnalyzerVersion); + } + + #endregion + + #region SerializeStatement Tests + + [Fact] + public void SerializeStatement_ProducesValidJson() + { + var graph = CreateTestGraph(); + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + var bytes = _builder.SerializeStatement(statement); + + Assert.NotEmpty(bytes); + var json = System.Text.Encoding.UTF8.GetString(bytes); + Assert.Contains("\"_type\":\"https://in-toto.io/Statement/v1\"", json); + Assert.Contains("\"predicateType\":\"https://stella.ops/reachabilityWitness/v1\"", json); + } + + [Fact] + public void SerializeStatement_IsDeterministic() + { + var graph = CreateTestGraph(); + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + var bytes1 = _builder.SerializeStatement(statement); + var bytes2 = _builder.SerializeStatement(statement); + + Assert.Equal(bytes1, bytes2); + } + + #endregion + + #region ComputeStatementHash Tests + + [Fact] + public void ComputeStatementHash_ReturnsBlake3Hash() + { + var graph = CreateTestGraph(); + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + var bytes = _builder.SerializeStatement(statement); + + var hash = _builder.ComputeStatementHash(bytes); + + Assert.StartsWith("blake3:", hash); + Assert.Equal(64 + 7, hash.Length); // "blake3:" + 64 hex chars + } + + [Fact] + public void ComputeStatementHash_IsDeterministic() + { + var graph = CreateTestGraph(); + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + var bytes = _builder.SerializeStatement(statement); + + var hash1 = _builder.ComputeStatementHash(bytes); + var hash2 = _builder.ComputeStatementHash(bytes); + + Assert.Equal(hash1, hash2); + } + + #endregion + + #region Edge Cases + + [Fact] + public void BuildStatement_ThrowsForNullGraph() + { + Assert.Throws(() => + _builder.BuildStatement(null!, "blake3:abc", "sha256:def")); + } + + [Fact] + public void BuildStatement_ThrowsForEmptyGraphHash() + { + var graph = CreateTestGraph(); + Assert.Throws(() => + _builder.BuildStatement(graph, "", "sha256:def")); + } + + [Fact] + public void BuildStatement_ThrowsForEmptySubjectDigest() + { + var graph = CreateTestGraph(); + Assert.Throws(() => + _builder.BuildStatement(graph, "blake3:abc", "")); + } + + [Fact] + public void BuildStatement_HandlesEmptyGraph() + { + var graph = new RichGraph( + Schema: "richgraph-v1", + Analyzer: new RichGraphAnalyzer("test", "1.0.0", null), + Nodes: Array.Empty(), + Edges: Array.Empty(), + Roots: null); + + var statement = _builder.BuildStatement( + graph, + graphHash: "blake3:abc123", + subjectDigest: "sha256:def456"); + + var predicate = statement.Predicate as ReachabilityWitnessStatement; + Assert.NotNull(predicate); + Assert.Equal(0, predicate.NodeCount); + Assert.Equal(0, predicate.EdgeCount); + Assert.Equal("unknown", predicate.Language); + } + + #endregion + + #region Test Helpers + + private static RichGraph CreateTestGraph() + { + return new RichGraph( + Schema: "richgraph-v1", + Analyzer: new RichGraphAnalyzer("test-analyzer", "1.0.0", null), + Nodes: new[] + { + new RichGraphNode("n1", "sym:dotnet:A", null, null, "dotnet", "method", "A", null, null, null, null), + new RichGraphNode("n2", "sym:dotnet:B", null, null, "dotnet", "method", "B", null, null, null, null), + new RichGraphNode("n3", "sym:dotnet:C", null, null, "dotnet", "sink", "C", null, null, null, null) + }, + Edges: new[] + { + new RichGraphEdge("n1", "n2", "call", null, null, null, 0.9, null), + new RichGraphEdge("n2", "n3", "call", null, null, null, 0.9, null) + }, + Roots: null); + } + + private static RichGraph CreateTestGraphWithRoots() + { + return new RichGraph( + Schema: "richgraph-v1", + Analyzer: new RichGraphAnalyzer("test-analyzer", "1.0.0", null), + Nodes: new[] + { + new RichGraphNode("n1", "sym:dotnet:A", null, null, "dotnet", "method", "A", null, null, null, null), + new RichGraphNode("n2", "sym:dotnet:B", null, null, "dotnet", "method", "B", null, null, null, null), + new RichGraphNode("n3", "sym:dotnet:C", null, null, "dotnet", "sink", "C", null, null, null, null) + }, + Edges: new[] + { + new RichGraphEdge("n1", "n2", "call", null, null, null, 0.9, null), + new RichGraphEdge("n2", "n3", "call", null, null, null, 0.9, null) + }, + Roots: new[] + { + new RichGraphRoot("n1", "http", "GET /api"), + new RichGraphRoot("n2", "grpc", "Service.Method") + }); + } + + private sealed class FakeTimeProvider : TimeProvider + { + private readonly DateTimeOffset _fixedTime; + + public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime; + + public override DateTimeOffset GetUtcNow() => _fixedTime; + } + + #endregion +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/RichGraphWriterTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/RichGraphWriterTests.cs index 49de1109..3901fe99 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/RichGraphWriterTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/RichGraphWriterTests.cs @@ -108,4 +108,30 @@ public class RichGraphWriterTests Assert.Contains("\"type\":\"authRequired\"", json); Assert.Contains("\"guard_symbol\":\"sym:dotnet:B\"", json); } + + [Fact] + public async Task UsesBlake3HashForDefaultProfile() + { + // WIT-013: Verify BLAKE3 is used for graph hashing + var writer = new RichGraphWriter(CryptoHashFactory.CreateDefault()); + using var temp = new TempDir(); + + var union = new ReachabilityUnionGraph( + Nodes: new[] + { + new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method", "A") + }, + Edges: Array.Empty()); + + var rich = RichGraphBuilder.FromUnion(union, "test-analyzer", "1.0.0"); + var result = await writer.WriteAsync(rich, temp.Path, "analysis-blake3"); + + // Default profile (world) uses BLAKE3 + Assert.StartsWith("blake3:", result.GraphHash); + Assert.Equal(64 + 7, result.GraphHash.Length); // "blake3:" (7) + 64 hex chars + + // Verify meta.json also contains the blake3-prefixed hash + var metaJson = await File.ReadAllTextAsync(result.MetaPath); + Assert.Contains("\"graph_hash\":\"blake3:", metaJson); + } } diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/FindingEvidenceContractsTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/FindingEvidenceContractsTests.cs new file mode 100644 index 00000000..1aa66a9e --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/FindingEvidenceContractsTests.cs @@ -0,0 +1,293 @@ +// ----------------------------------------------------------------------------- +// FindingEvidenceContractsTests.cs +// Sprint: SPRINT_3800_0001_0001_evidence_api_models +// Description: Unit tests for JSON serialization of evidence API contracts. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Text.Json; +using StellaOps.Scanner.WebService.Contracts; +using Xunit; + +namespace StellaOps.Scanner.WebService.Tests; + +public class FindingEvidenceContractsTests +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = false + }; + + [Fact] + public void FindingEvidenceResponse_SerializesToSnakeCase() + { + var response = new FindingEvidenceResponse + { + FindingId = "finding-123", + Cve = "CVE-2021-44228", + Component = new ComponentRef + { + Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + Name = "log4j-core", + Version = "2.14.1", + Type = "maven" + }, + ReachablePath = new[] { "com.example.App.main", "org.apache.log4j.Logger.log" }, + LastSeen = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero) + }; + + var json = JsonSerializer.Serialize(response, SerializerOptions); + + Assert.Contains("\"finding_id\":\"finding-123\"", json); + Assert.Contains("\"cve\":\"CVE-2021-44228\"", json); + Assert.Contains("\"reachable_path\":", json); + Assert.Contains("\"last_seen\":", json); + } + + [Fact] + public void FindingEvidenceResponse_RoundTripsCorrectly() + { + var original = new FindingEvidenceResponse + { + FindingId = "finding-456", + Cve = "CVE-2023-12345", + Component = new ComponentRef + { + Purl = "pkg:npm/lodash@4.17.20", + Name = "lodash", + Version = "4.17.20", + Type = "npm" + }, + Entrypoint = new EntrypointProof + { + Type = "http_handler", + Route = "/api/v1/users", + Method = "POST", + Auth = "required", + Fqn = "com.example.UserController.createUser" + }, + ScoreExplain = new ScoreExplanationDto + { + Kind = "stellaops_risk_v1", + RiskScore = 7.5, + Contributions = new[] + { + new ScoreContributionDto + { + Factor = "cvss_base", + Weight = 0.4, + RawValue = 9.8, + Contribution = 3.92, + Explanation = "CVSS v4 base score" + } + }, + LastSeen = DateTimeOffset.UtcNow + }, + LastSeen = DateTimeOffset.UtcNow + }; + + var json = JsonSerializer.Serialize(original, SerializerOptions); + var deserialized = JsonSerializer.Deserialize(json, SerializerOptions); + + Assert.NotNull(deserialized); + Assert.Equal(original.FindingId, deserialized.FindingId); + Assert.Equal(original.Cve, deserialized.Cve); + Assert.Equal(original.Component?.Purl, deserialized.Component?.Purl); + Assert.Equal(original.Entrypoint?.Type, deserialized.Entrypoint?.Type); + Assert.Equal(original.ScoreExplain?.RiskScore, deserialized.ScoreExplain?.RiskScore); + } + + [Fact] + public void ComponentRef_SerializesAllFields() + { + var component = new ComponentRef + { + Purl = "pkg:nuget/Newtonsoft.Json@13.0.1", + Name = "Newtonsoft.Json", + Version = "13.0.1", + Type = "nuget" + }; + + var json = JsonSerializer.Serialize(component, SerializerOptions); + + Assert.Contains("\"purl\":\"pkg:nuget/Newtonsoft.Json@13.0.1\"", json); + Assert.Contains("\"name\":\"Newtonsoft.Json\"", json); + Assert.Contains("\"version\":\"13.0.1\"", json); + Assert.Contains("\"type\":\"nuget\"", json); + } + + [Fact] + public void EntrypointProof_SerializesWithLocation() + { + var entrypoint = new EntrypointProof + { + Type = "grpc_method", + Route = "grpc.UserService.GetUser", + Auth = "required", + Phase = "runtime", + Fqn = "com.example.UserServiceImpl.getUser", + Location = new SourceLocation + { + File = "src/main/java/com/example/UserServiceImpl.java", + Line = 42, + Column = 5 + } + }; + + var json = JsonSerializer.Serialize(entrypoint, SerializerOptions); + + Assert.Contains("\"type\":\"grpc_method\"", json); + Assert.Contains("\"route\":\"grpc.UserService.GetUser\"", json); + Assert.Contains("\"location\":", json); + Assert.Contains("\"file\":\"src/main/java/com/example/UserServiceImpl.java\"", json); + Assert.Contains("\"line\":42", json); + } + + [Fact] + public void BoundaryProofDto_SerializesWithControls() + { + var boundary = new BoundaryProofDto + { + Kind = "network", + Surface = new SurfaceDescriptor + { + Type = "api", + Protocol = "https", + Port = 443 + }, + Exposure = new ExposureDescriptor + { + Level = "public", + InternetFacing = true, + Zone = "dmz" + }, + Auth = new AuthDescriptor + { + Required = true, + Type = "jwt", + Roles = new[] { "admin", "user" } + }, + Controls = new[] + { + new ControlDescriptor + { + Type = "waf", + Active = true, + Config = "OWASP-ModSecurity" + } + }, + LastSeen = DateTimeOffset.UtcNow, + Confidence = 0.95 + }; + + var json = JsonSerializer.Serialize(boundary, SerializerOptions); + + Assert.Contains("\"kind\":\"network\"", json); + Assert.Contains("\"internet_facing\":true", json); + Assert.Contains("\"controls\":[", json); + Assert.Contains("\"confidence\":0.95", json); + } + + [Fact] + public void VexEvidenceDto_SerializesCorrectly() + { + var vex = new VexEvidenceDto + { + Status = "not_affected", + Justification = "vulnerable_code_not_in_execute_path", + Impact = "The vulnerable code path is never executed in our usage", + AttestationRef = "dsse:sha256:abc123", + IssuedAt = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero), + ExpiresAt = new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.Zero), + Source = "vendor" + }; + + var json = JsonSerializer.Serialize(vex, SerializerOptions); + + Assert.Contains("\"status\":\"not_affected\"", json); + Assert.Contains("\"justification\":\"vulnerable_code_not_in_execute_path\"", json); + Assert.Contains("\"attestation_ref\":\"dsse:sha256:abc123\"", json); + Assert.Contains("\"source\":\"vendor\"", json); + } + + [Fact] + public void ScoreExplanationDto_SerializesContributions() + { + var explanation = new ScoreExplanationDto + { + Kind = "stellaops_risk_v1", + RiskScore = 6.2, + Contributions = new[] + { + new ScoreContributionDto + { + Factor = "cvss_base", + Weight = 0.4, + RawValue = 9.8, + Contribution = 3.92, + Explanation = "Critical CVSS base score" + }, + new ScoreContributionDto + { + Factor = "epss", + Weight = 0.2, + RawValue = 0.45, + Contribution = 0.09, + Explanation = "45% probability of exploitation" + }, + new ScoreContributionDto + { + Factor = "reachability", + Weight = 0.3, + RawValue = 1.0, + Contribution = 0.3, + Explanation = "Reachable from HTTP entrypoint" + }, + new ScoreContributionDto + { + Factor = "gate_multiplier", + Weight = 1.0, + RawValue = 0.5, + Contribution = -2.11, + Explanation = "Auth gate reduces exposure by 50%" + } + }, + LastSeen = DateTimeOffset.UtcNow + }; + + var json = JsonSerializer.Serialize(explanation, SerializerOptions); + + Assert.Contains("\"kind\":\"stellaops_risk_v1\"", json); + Assert.Contains("\"risk_score\":6.2", json); + Assert.Contains("\"contributions\":[", json); + Assert.Contains("\"factor\":\"cvss_base\"", json); + Assert.Contains("\"factor\":\"epss\"", json); + Assert.Contains("\"factor\":\"reachability\"", json); + Assert.Contains("\"factor\":\"gate_multiplier\"", json); + } + + [Fact] + public void NullOptionalFields_AreOmittedOrNullInJson() + { + var response = new FindingEvidenceResponse + { + FindingId = "finding-minimal", + Cve = "CVE-2025-0001", + LastSeen = DateTimeOffset.UtcNow + // All optional fields are null + }; + + var json = JsonSerializer.Serialize(response, SerializerOptions); + var deserialized = JsonSerializer.Deserialize(json, SerializerOptions); + + Assert.NotNull(deserialized); + Assert.Null(deserialized.Component); + Assert.Null(deserialized.ReachablePath); + Assert.Null(deserialized.Entrypoint); + Assert.Null(deserialized.Boundary); + Assert.Null(deserialized.Vex); + Assert.Null(deserialized.ScoreExplain); + } +} diff --git a/src/Signals/StellaOps.Signals.Storage.Postgres.Tests/CallGraphProjectionIntegrationTests.cs b/src/Signals/StellaOps.Signals.Storage.Postgres.Tests/CallGraphProjectionIntegrationTests.cs new file mode 100644 index 00000000..975e8ff3 --- /dev/null +++ b/src/Signals/StellaOps.Signals.Storage.Postgres.Tests/CallGraphProjectionIntegrationTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Signals.Models; +using StellaOps.Signals.Persistence; +using StellaOps.Signals.Services; +using StellaOps.Signals.Storage.Postgres.Repositories; +using Xunit; +using Xunit.Abstractions; + +namespace StellaOps.Signals.Storage.Postgres.Tests; + +/// +/// Integration tests for callgraph projection to relational tables. +/// +[Collection(SignalsPostgresCollection.Name)] +public sealed class CallGraphProjectionIntegrationTests +{ + private readonly SignalsPostgresFixture _fixture; + private readonly ITestOutputHelper _output; + + public CallGraphProjectionIntegrationTests(SignalsPostgresFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + [Fact] + public async Task SyncAsync_ProjectsNodesToRelationalTable() + { + // Arrange + var dataSource = await CreateDataSourceAsync(); + var repository = new PostgresCallGraphProjectionRepository( + dataSource, + NullLogger.Instance); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + + // Act + var result = await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert + Assert.True(result.WasUpdated); + Assert.Equal(document.Nodes.Count, result.NodesProjected); + Assert.Equal(document.Edges.Count, result.EdgesProjected); + Assert.Equal(document.Entrypoints.Count, result.EntrypointsProjected); + Assert.True(result.DurationMs >= 0); + + _output.WriteLine($"Projected {result.NodesProjected} nodes, {result.EdgesProjected} edges, {result.EntrypointsProjected} entrypoints in {result.DurationMs}ms"); + } + + [Fact] + public async Task SyncAsync_IsIdempotent_DoesNotCreateDuplicates() + { + // Arrange + var dataSource = await CreateDataSourceAsync(); + var repository = new PostgresCallGraphProjectionRepository( + dataSource, + NullLogger.Instance); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + + // Act - project twice + var result1 = await service.SyncAsync(scanId, "sha256:test-digest", document); + var result2 = await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert - second run should update, not duplicate + Assert.Equal(result1.NodesProjected, result2.NodesProjected); + Assert.Equal(result1.EdgesProjected, result2.EdgesProjected); + } + + [Fact] + public async Task SyncAsync_WithEntrypoints_ProjectsEntrypointsCorrectly() + { + // Arrange + var dataSource = await CreateDataSourceAsync(); + var repository = new PostgresCallGraphProjectionRepository( + dataSource, + NullLogger.Instance); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = new CallgraphDocument + { + Id = Guid.NewGuid().ToString("N"), + Language = "csharp", + GraphHash = "test-hash", + Nodes = new List + { + new() { Id = "node-1", Name = "GetUsers", Namespace = "Api.Controllers" }, + new() { Id = "node-2", Name = "CreateUser", Namespace = "Api.Controllers" } + }, + Edges = new List(), + Entrypoints = new List + { + new() { NodeId = "node-1", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "GET", Order = 0 }, + new() { NodeId = "node-2", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "POST", Order = 1 } + } + }; + + // Act + var result = await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert + Assert.Equal(2, result.EntrypointsProjected); + _output.WriteLine($"Projected {result.EntrypointsProjected} HTTP entrypoints"); + } + + [Fact] + public async Task DeleteByScanAsync_RemovesAllProjectedData() + { + // Arrange + var dataSource = await CreateDataSourceAsync(); + var repository = new PostgresCallGraphProjectionRepository( + dataSource, + NullLogger.Instance); + var queryRepository = new PostgresCallGraphQueryRepository( + dataSource, + NullLogger.Instance); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + + // Project first + await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Act + await service.DeleteByScanAsync(scanId); + + // Assert - query should return empty stats + var stats = await queryRepository.GetStatsAsync(scanId); + Assert.Equal(0, stats.NodeCount); + Assert.Equal(0, stats.EdgeCount); + } + + [Fact] + public async Task QueryRepository_CanQueryProjectedData() + { + // Arrange + var dataSource = await CreateDataSourceAsync(); + var repository = new PostgresCallGraphProjectionRepository( + dataSource, + NullLogger.Instance); + var queryRepository = new PostgresCallGraphQueryRepository( + dataSource, + NullLogger.Instance); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + + // Project + await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Act + var stats = await queryRepository.GetStatsAsync(scanId); + + // Assert + Assert.Equal(document.Nodes.Count, stats.NodeCount); + Assert.Equal(document.Edges.Count, stats.EdgeCount); + _output.WriteLine($"Query returned: {stats.NodeCount} nodes, {stats.EdgeCount} edges"); + } + + private async Task CreateDataSourceAsync() + { + var connectionString = _fixture.GetConnectionString(); + var options = new Microsoft.Extensions.Options.OptionsWrapper( + new StellaOps.Infrastructure.Postgres.Options.PostgresOptions { ConnectionString = connectionString }); + var dataSource = new SignalsDataSource(options); + + // Run migration + await _fixture.RunMigrationsAsync(); + + return dataSource; + } + + private static CallgraphDocument CreateSampleDocument() + { + return new CallgraphDocument + { + Id = Guid.NewGuid().ToString("N"), + Language = "csharp", + GraphHash = "sha256:sample-graph-hash", + Nodes = new List + { + new() { Id = "node-1", Name = "Main", Kind = "method", Namespace = "Program", Visibility = SymbolVisibility.Public, IsEntrypointCandidate = true }, + new() { Id = "node-2", Name = "DoWork", Kind = "method", Namespace = "Service", Visibility = SymbolVisibility.Internal }, + new() { Id = "node-3", Name = "ProcessData", Kind = "method", Namespace = "Core", Visibility = SymbolVisibility.Private } + }, + Edges = new List + { + new() { SourceId = "node-1", TargetId = "node-2", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 }, + new() { SourceId = "node-2", TargetId = "node-3", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 } + }, + Entrypoints = new List + { + new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 } + } + }; + } +} diff --git a/src/Signals/StellaOps.Signals.Storage.Postgres/Repositories/PostgresCallGraphProjectionRepository.cs b/src/Signals/StellaOps.Signals.Storage.Postgres/Repositories/PostgresCallGraphProjectionRepository.cs new file mode 100644 index 00000000..8e23e828 --- /dev/null +++ b/src/Signals/StellaOps.Signals.Storage.Postgres/Repositories/PostgresCallGraphProjectionRepository.cs @@ -0,0 +1,466 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using StellaOps.Infrastructure.Postgres.Repositories; +using StellaOps.Signals.Models; +using StellaOps.Signals.Persistence; + +namespace StellaOps.Signals.Storage.Postgres.Repositories; + +/// +/// PostgreSQL implementation of . +/// Projects callgraph documents into relational tables for efficient querying. +/// +public sealed class PostgresCallGraphProjectionRepository : RepositoryBase, ICallGraphProjectionRepository +{ + private const int BatchSize = 1000; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public PostgresCallGraphProjectionRepository( + SignalsDataSource dataSource, + ILogger logger) + : base(dataSource, logger) + { + } + + /// + public async Task UpsertScanAsync( + Guid scanId, + string artifactDigest, + string? sbomDigest = null, + string? repoUri = null, + string? commitSha = null, + CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO signals.scans (scan_id, artifact_digest, sbom_digest, repo_uri, commit_sha, status, created_at) + VALUES (@scan_id, @artifact_digest, @sbom_digest, @repo_uri, @commit_sha, 'processing', NOW()) + ON CONFLICT (scan_id) + DO UPDATE SET + artifact_digest = EXCLUDED.artifact_digest, + sbom_digest = COALESCE(EXCLUDED.sbom_digest, signals.scans.sbom_digest), + repo_uri = COALESCE(EXCLUDED.repo_uri, signals.scans.repo_uri), + commit_sha = COALESCE(EXCLUDED.commit_sha, signals.scans.commit_sha), + status = CASE WHEN signals.scans.status = 'completed' THEN 'completed' ELSE 'processing' END + RETURNING (xmax = 0) AS was_inserted + """; + + await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = CreateCommand(sql, connection); + + AddParameter(command, "@scan_id", scanId); + AddParameter(command, "@artifact_digest", artifactDigest); + AddParameter(command, "@sbom_digest", sbomDigest ?? (object)DBNull.Value); + AddParameter(command, "@repo_uri", repoUri ?? (object)DBNull.Value); + AddParameter(command, "@commit_sha", commitSha ?? (object)DBNull.Value); + + var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return result is true; + } + + /// + public async Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default) + { + const string sql = """ + UPDATE signals.scans + SET status = 'completed', completed_at = NOW() + WHERE scan_id = @scan_id + """; + + await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = CreateCommand(sql, connection); + AddParameter(command, "@scan_id", scanId); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default) + { + const string sql = """ + UPDATE signals.scans + SET status = 'failed', error_message = @error_message, completed_at = NOW() + WHERE scan_id = @scan_id + """; + + await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = CreateCommand(sql, connection); + AddParameter(command, "@scan_id", scanId); + AddParameter(command, "@error_message", errorMessage); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task UpsertNodesAsync( + Guid scanId, + IReadOnlyList nodes, + CancellationToken cancellationToken = default) + { + if (nodes is not { Count: > 0 }) + { + return 0; + } + + // Sort nodes deterministically by Id for stable ordering + var sortedNodes = nodes.OrderBy(n => n.Id, StringComparer.Ordinal).ToList(); + + var totalInserted = 0; + + await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + try + { + // Process in batches + for (var i = 0; i < sortedNodes.Count; i += BatchSize) + { + var batch = sortedNodes.Skip(i).Take(BatchSize).ToList(); + totalInserted += await UpsertNodeBatchAsync(connection, transaction, scanId, batch, cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return totalInserted; + } + catch + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw; + } + } + + private async Task UpsertNodeBatchAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid scanId, + IReadOnlyList nodes, + CancellationToken cancellationToken) + { + var sql = new StringBuilder(); + sql.AppendLine(""" + INSERT INTO signals.cg_nodes (scan_id, node_id, artifact_key, symbol_key, visibility, is_entrypoint_candidate, purl, symbol_digest, flags, attributes) + VALUES + """); + + var parameters = new List(); + var paramIndex = 0; + + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + if (i > 0) sql.Append(','); + + sql.AppendLine($""" + (@p{paramIndex}, @p{paramIndex + 1}, @p{paramIndex + 2}, @p{paramIndex + 3}, @p{paramIndex + 4}, @p{paramIndex + 5}, @p{paramIndex + 6}, @p{paramIndex + 7}, @p{paramIndex + 8}, @p{paramIndex + 9}) + """); + + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", scanId)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.Id)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.Namespace ?? (object)DBNull.Value)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", BuildSymbolKey(node))); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", MapVisibility(node))); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.IsEntrypointCandidate)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.Purl ?? (object)DBNull.Value)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.SymbolDigest ?? (object)DBNull.Value)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", MapNodeFlags(node))); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", NpgsqlDbType.Jsonb) { Value = SerializeAttributes(node) ?? DBNull.Value }); + } + + sql.AppendLine(""" + ON CONFLICT (scan_id, node_id) + DO UPDATE SET + artifact_key = EXCLUDED.artifact_key, + symbol_key = EXCLUDED.symbol_key, + visibility = EXCLUDED.visibility, + is_entrypoint_candidate = EXCLUDED.is_entrypoint_candidate, + purl = EXCLUDED.purl, + symbol_digest = EXCLUDED.symbol_digest, + flags = EXCLUDED.flags, + attributes = EXCLUDED.attributes + """); + + await using var command = new NpgsqlCommand(sql.ToString(), connection, transaction); + command.Parameters.AddRange(parameters.ToArray()); + + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task UpsertEdgesAsync( + Guid scanId, + IReadOnlyList edges, + CancellationToken cancellationToken = default) + { + if (edges is not { Count: > 0 }) + { + return 0; + } + + // Sort edges deterministically by (SourceId, TargetId) for stable ordering + var sortedEdges = edges + .OrderBy(e => e.SourceId, StringComparer.Ordinal) + .ThenBy(e => e.TargetId, StringComparer.Ordinal) + .ToList(); + + var totalInserted = 0; + + await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + try + { + // Process in batches + for (var i = 0; i < sortedEdges.Count; i += BatchSize) + { + var batch = sortedEdges.Skip(i).Take(BatchSize).ToList(); + totalInserted += await UpsertEdgeBatchAsync(connection, transaction, scanId, batch, cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return totalInserted; + } + catch + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw; + } + } + + private async Task UpsertEdgeBatchAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid scanId, + IReadOnlyList edges, + CancellationToken cancellationToken) + { + var sql = new StringBuilder(); + sql.AppendLine(""" + INSERT INTO signals.cg_edges (scan_id, from_node_id, to_node_id, kind, reason, weight, is_resolved, provenance) + VALUES + """); + + var parameters = new List(); + var paramIndex = 0; + + for (var i = 0; i < edges.Count; i++) + { + var edge = edges[i]; + if (i > 0) sql.Append(','); + + sql.AppendLine($""" + (@p{paramIndex}, @p{paramIndex + 1}, @p{paramIndex + 2}, @p{paramIndex + 3}, @p{paramIndex + 4}, @p{paramIndex + 5}, @p{paramIndex + 6}, @p{paramIndex + 7}) + """); + + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", scanId)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.SourceId)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.TargetId)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", (short)MapEdgeKind(edge))); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", (short)MapEdgeReason(edge))); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", (float)(edge.Confidence ?? 1.0))); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.IsResolved)); + parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.Provenance ?? (object)DBNull.Value)); + } + + sql.AppendLine(""" + ON CONFLICT (scan_id, from_node_id, to_node_id, kind, reason) + DO UPDATE SET + weight = EXCLUDED.weight, + is_resolved = EXCLUDED.is_resolved, + provenance = EXCLUDED.provenance + """); + + await using var command = new NpgsqlCommand(sql.ToString(), connection, transaction); + command.Parameters.AddRange(parameters.ToArray()); + + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task UpsertEntrypointsAsync( + Guid scanId, + IReadOnlyList entrypoints, + CancellationToken cancellationToken = default) + { + if (entrypoints is not { Count: > 0 }) + { + return 0; + } + + // Sort entrypoints deterministically by (NodeId, Order) for stable ordering + var sortedEntrypoints = entrypoints + .OrderBy(e => e.NodeId, StringComparer.Ordinal) + .ThenBy(e => e.Order) + .ToList(); + + const string sql = """ + INSERT INTO signals.entrypoints (scan_id, node_id, kind, framework, route, http_method, phase, order_idx) + VALUES (@scan_id, @node_id, @kind, @framework, @route, @http_method, @phase, @order_idx) + ON CONFLICT (scan_id, node_id, kind) + DO UPDATE SET + framework = EXCLUDED.framework, + route = EXCLUDED.route, + http_method = EXCLUDED.http_method, + phase = EXCLUDED.phase, + order_idx = EXCLUDED.order_idx + """; + + var totalInserted = 0; + + await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + try + { + foreach (var entrypoint in sortedEntrypoints) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + + command.Parameters.AddWithValue("@scan_id", scanId); + command.Parameters.AddWithValue("@node_id", entrypoint.NodeId); + command.Parameters.AddWithValue("@kind", MapEntrypointKind(entrypoint.Kind)); + command.Parameters.AddWithValue("@framework", entrypoint.Framework.ToString().ToLowerInvariant()); + command.Parameters.AddWithValue("@route", entrypoint.Route ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@http_method", entrypoint.HttpMethod ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@phase", MapEntrypointPhase(entrypoint.Phase)); + command.Parameters.AddWithValue("@order_idx", entrypoint.Order); + + totalInserted += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return totalInserted; + } + catch + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw; + } + } + + /// + public async Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default) + { + // Delete from scans cascades to all related tables via FK + const string sql = "DELETE FROM signals.scans WHERE scan_id = @scan_id"; + + await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = CreateCommand(sql, connection); + AddParameter(command, "@scan_id", scanId); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + // ===== HELPER METHODS ===== + + private static string BuildSymbolKey(CallgraphNode node) + { + // Build canonical symbol key: namespace.name or just name + if (!string.IsNullOrWhiteSpace(node.Namespace)) + { + return $"{node.Namespace}.{node.Name}"; + } + return node.Name; + } + + private static string MapVisibility(CallgraphNode node) + { + return node.Visibility switch + { + SymbolVisibility.Public => "public", + SymbolVisibility.Internal => "internal", + SymbolVisibility.Protected => "protected", + SymbolVisibility.Private => "private", + _ => "unknown" + }; + } + + private static int MapNodeFlags(CallgraphNode node) + { + // Use the Flags property directly from the node + // The Flags bitfield is already encoded by the parser + return node.Flags; + } + + private static string? SerializeAttributes(CallgraphNode node) + { + // Serialize additional attributes if present + if (node.Evidence is not { Count: > 0 }) + { + return null; + } + + return JsonSerializer.Serialize(new { evidence = node.Evidence }, JsonOptions); + } + + private static EdgeKind MapEdgeKind(CallgraphEdge edge) + { + return edge.Kind switch + { + EdgeKind.Static => EdgeKind.Static, + EdgeKind.Heuristic => EdgeKind.Heuristic, + EdgeKind.Runtime => EdgeKind.Runtime, + _ => edge.Type?.ToLowerInvariant() switch + { + "static" => EdgeKind.Static, + "heuristic" => EdgeKind.Heuristic, + "runtime" => EdgeKind.Runtime, + _ => EdgeKind.Static + } + }; + } + + private static EdgeReason MapEdgeReason(CallgraphEdge edge) + { + return edge.Reason switch + { + EdgeReason.DirectCall => EdgeReason.DirectCall, + EdgeReason.VirtualCall => EdgeReason.VirtualCall, + EdgeReason.ReflectionString => EdgeReason.ReflectionString, + EdgeReason.RuntimeMinted => EdgeReason.RuntimeMinted, + _ => EdgeReason.DirectCall + }; + } + + private static string MapEntrypointKind(EntrypointKind kind) + { + return kind switch + { + EntrypointKind.Http => "http", + EntrypointKind.Grpc => "grpc", + EntrypointKind.Cli => "cli", + EntrypointKind.Job => "job", + EntrypointKind.Event => "event", + EntrypointKind.MessageQueue => "message_queue", + EntrypointKind.Timer => "timer", + EntrypointKind.Test => "test", + EntrypointKind.Main => "main", + EntrypointKind.ModuleInit => "module_init", + EntrypointKind.StaticConstructor => "static_constructor", + _ => "unknown" + }; + } + + private static string MapEntrypointPhase(EntrypointPhase phase) + { + return phase switch + { + EntrypointPhase.ModuleInit => "module_init", + EntrypointPhase.AppStart => "app_start", + EntrypointPhase.Runtime => "runtime", + EntrypointPhase.Shutdown => "shutdown", + _ => "runtime" + }; + } +} diff --git a/src/Signals/StellaOps.Signals.Storage.Postgres/ServiceCollectionExtensions.cs b/src/Signals/StellaOps.Signals.Storage.Postgres/ServiceCollectionExtensions.cs index a68e93a9..07a3ed5f 100644 --- a/src/Signals/StellaOps.Signals.Storage.Postgres/ServiceCollectionExtensions.cs +++ b/src/Signals/StellaOps.Signals.Storage.Postgres/ServiceCollectionExtensions.cs @@ -34,6 +34,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } @@ -59,6 +60,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/Signals/StellaOps.Signals/Models/ScoreExplanation.cs b/src/Signals/StellaOps.Signals/Models/ScoreExplanation.cs new file mode 100644 index 00000000..e71aa46b --- /dev/null +++ b/src/Signals/StellaOps.Signals/Models/ScoreExplanation.cs @@ -0,0 +1,192 @@ +// ----------------------------------------------------------------------------- +// ScoreExplanation.cs +// Sprint: SPRINT_3800_0001_0001_evidence_api_models +// Description: Score explanation model with additive breakdown of risk factors. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StellaOps.Signals.Models; + +/// +/// Score explanation with additive breakdown of risk factors. +/// Provides transparency into how a risk score was computed. +/// +public sealed record ScoreExplanation +{ + /// + /// Kind of scoring algorithm (stellaops_risk_v1, cvss_v4, custom). + /// + [JsonPropertyName("kind")] + public string Kind { get; init; } = "stellaops_risk_v1"; + + /// + /// Final computed risk score (0.0 to 10.0 or custom range). + /// + [JsonPropertyName("risk_score")] + public double RiskScore { get; init; } + + /// + /// Individual score contributions summing to the final score. + /// + [JsonPropertyName("contributions")] + public IReadOnlyList Contributions { get; init; } = Array.Empty(); + + /// + /// When the score was computed. + /// + [JsonPropertyName("last_seen")] + public DateTimeOffset LastSeen { get; init; } + + /// + /// Version of the scoring algorithm. + /// + [JsonPropertyName("algorithm_version")] + public string? AlgorithmVersion { get; init; } + + /// + /// Reference to the evidence used for scoring (scan ID, graph hash, etc.). + /// + [JsonPropertyName("evidence_ref")] + public string? EvidenceRef { get; init; } + + /// + /// Human-readable summary of the score. + /// + [JsonPropertyName("summary")] + public string? Summary { get; init; } + + /// + /// Any modifiers applied after base calculation (caps, floors, policy overrides). + /// + [JsonPropertyName("modifiers")] + public IReadOnlyList? Modifiers { get; init; } +} + +/// +/// Individual contribution to the risk score. +/// +public sealed record ScoreContribution +{ + /// + /// Factor name (cvss_base, epss, reachability, gate_multiplier, vex_override, etc.). + /// + [JsonPropertyName("factor")] + public string Factor { get; init; } = string.Empty; + + /// + /// Weight applied to this factor (0.0 to 1.0). + /// + [JsonPropertyName("weight")] + public double Weight { get; init; } + + /// + /// Raw value before weighting. + /// + [JsonPropertyName("raw_value")] + public double RawValue { get; init; } + + /// + /// Weighted contribution to final score. + /// + [JsonPropertyName("contribution")] + public double Contribution { get; init; } + + /// + /// Human-readable explanation of this factor. + /// + [JsonPropertyName("explanation")] + public string? Explanation { get; init; } + + /// + /// Source of the factor value (nvd, first, scan, vex, policy). + /// + [JsonPropertyName("source")] + public string? Source { get; init; } + + /// + /// When this factor value was last updated. + /// + [JsonPropertyName("updated_at")] + public DateTimeOffset? UpdatedAt { get; init; } + + /// + /// Confidence in this factor (0.0 to 1.0). + /// + [JsonPropertyName("confidence")] + public double? Confidence { get; init; } +} + +/// +/// Modifier applied to the score after base calculation. +/// +public sealed record ScoreModifier +{ + /// + /// Type of modifier (cap, floor, policy_override, vex_reduction, etc.). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + /// + /// Original value before modifier. + /// + [JsonPropertyName("before")] + public double Before { get; init; } + + /// + /// Value after modifier. + /// + [JsonPropertyName("after")] + public double After { get; init; } + + /// + /// Reason for the modifier. + /// + [JsonPropertyName("reason")] + public string? Reason { get; init; } + + /// + /// Policy or rule that triggered the modifier. + /// + [JsonPropertyName("policy_ref")] + public string? PolicyRef { get; init; } +} + +/// +/// Well-known score factor names. +/// +public static class ScoreFactors +{ + /// CVSS v4 base score. + public const string CvssBase = "cvss_base"; + + /// CVSS v4 environmental score. + public const string CvssEnvironmental = "cvss_environmental"; + + /// EPSS probability score. + public const string Epss = "epss"; + + /// Reachability analysis result. + public const string Reachability = "reachability"; + + /// Gate-based multiplier (auth, feature flags, etc.). + public const string GateMultiplier = "gate_multiplier"; + + /// VEX-based status override. + public const string VexOverride = "vex_override"; + + /// Time-based decay (older vulnerabilities). + public const string TimeDecay = "time_decay"; + + /// Exposure surface multiplier. + public const string ExposureSurface = "exposure_surface"; + + /// Known exploitation status (KEV, etc.). + public const string KnownExploitation = "known_exploitation"; + + /// Asset criticality multiplier. + public const string AssetCriticality = "asset_criticality"; +} diff --git a/src/Signals/StellaOps.Signals/Options/ScoreExplanationWeights.cs b/src/Signals/StellaOps.Signals/Options/ScoreExplanationWeights.cs new file mode 100644 index 00000000..4612f163 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Options/ScoreExplanationWeights.cs @@ -0,0 +1,128 @@ +// ----------------------------------------------------------------------------- +// ScoreExplanationWeights.cs +// Sprint: SPRINT_3800_0001_0002_score_explanation_service +// Description: Configurable weights for additive score explanation. +// ----------------------------------------------------------------------------- + +using System; + +namespace StellaOps.Signals.Options; + +/// +/// Configurable weights for the additive score explanation model. +/// Total score is computed as sum of weighted contributions (0-100 range). +/// +public sealed class ScoreExplanationWeights +{ + /// + /// Multiplier for CVSS base score (10.0 CVSS × 5.0 = 50 points max). + /// + public double CvssMultiplier { get; set; } = 5.0; + + /// + /// Points when path reaches entrypoint directly. + /// + public double EntrypointReachability { get; set; } = 25.0; + + /// + /// Points for direct reachability (caller directly invokes vulnerable code). + /// + public double DirectReachability { get; set; } = 20.0; + + /// + /// Points for runtime-observed reachability. + /// + public double RuntimeReachability { get; set; } = 22.0; + + /// + /// Points for unknown reachability status. + /// + public double UnknownReachability { get; set; } = 12.0; + + /// + /// Points for unreachable paths (typically 0). + /// + public double UnreachableReachability { get; set; } = 0.0; + + /// + /// Points for HTTP/HTTPS exposed entrypoints. + /// + public double HttpExposure { get; set; } = 15.0; + + /// + /// Points for gRPC exposed entrypoints. + /// + public double GrpcExposure { get; set; } = 12.0; + + /// + /// Points for internal-only exposure (not internet-facing). + /// + public double InternalExposure { get; set; } = 5.0; + + /// + /// Points for CLI or scheduled task exposure. + /// + public double CliExposure { get; set; } = 3.0; + + /// + /// Discount (negative) when auth gate is detected. + /// + public double AuthGateDiscount { get; set; } = -3.0; + + /// + /// Discount (negative) when admin-only gate is detected. + /// + public double AdminGateDiscount { get; set; } = -5.0; + + /// + /// Discount (negative) when feature flag gate is detected. + /// + public double FeatureFlagDiscount { get; set; } = -2.0; + + /// + /// Discount (negative) when non-default config gate is detected. + /// + public double NonDefaultConfigDiscount { get; set; } = -2.0; + + /// + /// Multiplier for EPSS probability (0.0-1.0 → 0-10 points). + /// + public double EpssMultiplier { get; set; } = 10.0; + + /// + /// Bonus for known exploited vulnerabilities (KEV). + /// + public double KevBonus { get; set; } = 10.0; + + /// + /// Minimum score floor. + /// + public double MinScore { get; set; } = 0.0; + + /// + /// Maximum score ceiling. + /// + public double MaxScore { get; set; } = 100.0; + + /// + /// Validates the configuration. + /// + public void Validate() + { + if (CvssMultiplier < 0) + throw new ArgumentOutOfRangeException(nameof(CvssMultiplier), CvssMultiplier, "Must be non-negative."); + + if (MinScore >= MaxScore) + throw new ArgumentException("MinScore must be less than MaxScore."); + + // Discounts should be negative or zero + if (AuthGateDiscount > 0) + throw new ArgumentOutOfRangeException(nameof(AuthGateDiscount), AuthGateDiscount, "Discounts should be negative or zero."); + + if (AdminGateDiscount > 0) + throw new ArgumentOutOfRangeException(nameof(AdminGateDiscount), AdminGateDiscount, "Discounts should be negative or zero."); + + if (FeatureFlagDiscount > 0) + throw new ArgumentOutOfRangeException(nameof(FeatureFlagDiscount), FeatureFlagDiscount, "Discounts should be negative or zero."); + } +} diff --git a/src/Signals/StellaOps.Signals/Options/SignalsScoringOptions.cs b/src/Signals/StellaOps.Signals/Options/SignalsScoringOptions.cs index 08571e2d..d45ad0e3 100644 --- a/src/Signals/StellaOps.Signals/Options/SignalsScoringOptions.cs +++ b/src/Signals/StellaOps.Signals/Options/SignalsScoringOptions.cs @@ -12,6 +12,11 @@ public sealed class SignalsScoringOptions /// public SignalsGateMultiplierOptions GateMultipliers { get; } = new(); + /// + /// Score explanation weights for additive risk scoring (Sprint: SPRINT_3800_0001_0002). + /// + public ScoreExplanationWeights ExplanationWeights { get; } = new(); + /// /// Confidence assigned when a path exists from entry point to target. /// @@ -68,6 +73,7 @@ public sealed class SignalsScoringOptions public void Validate() { GateMultipliers.Validate(); + ExplanationWeights.Validate(); EnsurePercent(nameof(ReachableConfidence), ReachableConfidence); EnsurePercent(nameof(UnreachableConfidence), UnreachableConfidence); diff --git a/src/Signals/StellaOps.Signals/Persistence/ICallGraphProjectionRepository.cs b/src/Signals/StellaOps.Signals/Persistence/ICallGraphProjectionRepository.cs new file mode 100644 index 00000000..c9c7aac7 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Persistence/ICallGraphProjectionRepository.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Persistence; + +/// +/// Repository for projecting callgraph documents into relational tables. +/// +public interface ICallGraphProjectionRepository +{ + /// + /// Upserts or creates a scan record. + /// + /// The scan identifier. + /// The artifact digest. + /// Optional SBOM digest. + /// Optional repository URI. + /// Optional commit SHA. + /// Cancellation token. + /// True if created, false if already existed. + Task UpsertScanAsync( + Guid scanId, + string artifactDigest, + string? sbomDigest = null, + string? repoUri = null, + string? commitSha = null, + CancellationToken cancellationToken = default); + + /// + /// Marks a scan as completed. + /// + Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default); + + /// + /// Marks a scan as failed. + /// + Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default); + + /// + /// Upserts nodes into the relational cg_nodes table. + /// + /// The scan identifier. + /// The nodes to upsert. + /// Cancellation token. + /// Number of nodes upserted. + Task UpsertNodesAsync( + Guid scanId, + IReadOnlyList nodes, + CancellationToken cancellationToken = default); + + /// + /// Upserts edges into the relational cg_edges table. + /// + /// The scan identifier. + /// The edges to upsert. + /// Cancellation token. + /// Number of edges upserted. + Task UpsertEdgesAsync( + Guid scanId, + IReadOnlyList edges, + CancellationToken cancellationToken = default); + + /// + /// Upserts entrypoints into the relational entrypoints table. + /// + /// The scan identifier. + /// The entrypoints to upsert. + /// Cancellation token. + /// Number of entrypoints upserted. + Task UpsertEntrypointsAsync( + Guid scanId, + IReadOnlyList entrypoints, + CancellationToken cancellationToken = default); + + /// + /// Deletes all relational data for a scan (cascading via FK). + /// + /// The scan identifier. + /// Cancellation token. + Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default); +} diff --git a/src/Signals/StellaOps.Signals/Persistence/InMemoryCallGraphProjectionRepository.cs b/src/Signals/StellaOps.Signals/Persistence/InMemoryCallGraphProjectionRepository.cs new file mode 100644 index 00000000..88661566 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Persistence/InMemoryCallGraphProjectionRepository.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Persistence; + +/// +/// In-memory implementation of for testing. +/// +public sealed class InMemoryCallGraphProjectionRepository : ICallGraphProjectionRepository +{ + private readonly ConcurrentDictionary _scans = new(); + private readonly ConcurrentDictionary<(Guid ScanId, string NodeId), NodeRecord> _nodes = new(); + private readonly ConcurrentDictionary<(Guid ScanId, string FromId, string ToId), EdgeRecord> _edges = new(); + private readonly ConcurrentDictionary<(Guid ScanId, string NodeId, string Kind), EntrypointRecord> _entrypoints = new(); + + public Task UpsertScanAsync( + Guid scanId, + string artifactDigest, + string? sbomDigest = null, + string? repoUri = null, + string? commitSha = null, + CancellationToken cancellationToken = default) + { + var wasInserted = !_scans.ContainsKey(scanId); + _scans[scanId] = new ScanRecord(scanId, artifactDigest, sbomDigest, repoUri, commitSha, "processing", null); + return Task.FromResult(wasInserted); + } + + public Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default) + { + if (_scans.TryGetValue(scanId, out var scan)) + { + _scans[scanId] = scan with { Status = "completed", CompletedAt = DateTimeOffset.UtcNow }; + } + return Task.CompletedTask; + } + + public Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default) + { + if (_scans.TryGetValue(scanId, out var scan)) + { + _scans[scanId] = scan with { Status = "failed", ErrorMessage = errorMessage, CompletedAt = DateTimeOffset.UtcNow }; + } + return Task.CompletedTask; + } + + public Task UpsertNodesAsync( + Guid scanId, + IReadOnlyList nodes, + CancellationToken cancellationToken = default) + { + var count = 0; + foreach (var node in nodes.OrderBy(n => n.Id, StringComparer.Ordinal)) + { + var key = (scanId, node.Id); + _nodes[key] = new NodeRecord(scanId, node.Id, node.Name, node.Namespace, node.Purl); + count++; + } + return Task.FromResult(count); + } + + public Task UpsertEdgesAsync( + Guid scanId, + IReadOnlyList edges, + CancellationToken cancellationToken = default) + { + var count = 0; + foreach (var edge in edges.OrderBy(e => e.SourceId, StringComparer.Ordinal) + .ThenBy(e => e.TargetId, StringComparer.Ordinal)) + { + var key = (scanId, edge.SourceId, edge.TargetId); + _edges[key] = new EdgeRecord(scanId, edge.SourceId, edge.TargetId, edge.Kind.ToString(), edge.Weight); + count++; + } + return Task.FromResult(count); + } + + public Task UpsertEntrypointsAsync( + Guid scanId, + IReadOnlyList entrypoints, + CancellationToken cancellationToken = default) + { + var count = 0; + foreach (var ep in entrypoints.OrderBy(e => e.NodeId, StringComparer.Ordinal)) + { + var key = (scanId, ep.NodeId, ep.Kind.ToString()); + _entrypoints[key] = new EntrypointRecord(scanId, ep.NodeId, ep.Kind.ToString(), ep.Route, ep.HttpMethod); + count++; + } + return Task.FromResult(count); + } + + public Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default) + { + _scans.TryRemove(scanId, out _); + + foreach (var key in _nodes.Keys.Where(k => k.ScanId == scanId).ToList()) + { + _nodes.TryRemove(key, out _); + } + + foreach (var key in _edges.Keys.Where(k => k.ScanId == scanId).ToList()) + { + _edges.TryRemove(key, out _); + } + + foreach (var key in _entrypoints.Keys.Where(k => k.ScanId == scanId).ToList()) + { + _entrypoints.TryRemove(key, out _); + } + + return Task.CompletedTask; + } + + // Accessors for testing + public IReadOnlyDictionary Scans => _scans; + public IReadOnlyDictionary<(Guid ScanId, string NodeId), NodeRecord> Nodes => _nodes; + public IReadOnlyDictionary<(Guid ScanId, string FromId, string ToId), EdgeRecord> Edges => _edges; + public IReadOnlyDictionary<(Guid ScanId, string NodeId, string Kind), EntrypointRecord> Entrypoints => _entrypoints; + + public sealed record ScanRecord( + Guid ScanId, + string ArtifactDigest, + string? SbomDigest, + string? RepoUri, + string? CommitSha, + string Status, + DateTimeOffset? CompletedAt, + string? ErrorMessage = null); + + public sealed record NodeRecord( + Guid ScanId, + string NodeId, + string Name, + string? Namespace, + string? Purl); + + public sealed record EdgeRecord( + Guid ScanId, + string FromId, + string ToId, + string Kind, + double Weight); + + public sealed record EntrypointRecord( + Guid ScanId, + string NodeId, + string Kind, + string? Route, + string? HttpMethod); +} diff --git a/src/Signals/StellaOps.Signals/Program.cs b/src/Signals/StellaOps.Signals/Program.cs index 00e3fb79..9a203ef6 100644 --- a/src/Signals/StellaOps.Signals/Program.cs +++ b/src/Signals/StellaOps.Signals/Program.cs @@ -83,6 +83,7 @@ builder.Services.AddRouting(options => options.LowercaseUrls = true); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Configure callgraph artifact storage based on driver if (bootstrap.Storage.IsRustFsDriver()) @@ -117,6 +118,7 @@ builder.Services.AddSingleton(new SimpleJsonCallgraphParser("p builder.Services.AddSingleton(new SimpleJsonCallgraphParser("go")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; @@ -197,6 +199,7 @@ builder.Services.AddSingleton(sp => eventBuilder); }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Sprint: SPRINT_3800_0001_0002 builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs b/src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs new file mode 100644 index 00000000..3b670fd6 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs @@ -0,0 +1,118 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using StellaOps.Signals.Models; +using StellaOps.Signals.Persistence; + +namespace StellaOps.Signals.Services; + +/// +/// Synchronizes canonical callgraph documents to relational tables. +/// +internal sealed class CallGraphSyncService : ICallGraphSyncService +{ + private readonly ICallGraphProjectionRepository _projectionRepository; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public CallGraphSyncService( + ICallGraphProjectionRepository projectionRepository, + TimeProvider timeProvider, + ILogger logger) + { + _projectionRepository = projectionRepository ?? throw new ArgumentNullException(nameof(projectionRepository)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task SyncAsync( + Guid scanId, + string artifactDigest, + CallgraphDocument document, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest); + + var stopwatch = Stopwatch.StartNew(); + + _logger.LogInformation( + "Starting callgraph projection for scan {ScanId}, artifact {ArtifactDigest}, nodes={NodeCount}, edges={EdgeCount}", + scanId, artifactDigest, document.Nodes.Count, document.Edges.Count); + + try + { + // Step 1: Upsert scan record + await _projectionRepository.UpsertScanAsync( + scanId, + artifactDigest, + document.GraphHash, + cancellationToken: cancellationToken).ConfigureAwait(false); + + // Step 2: Project nodes in stable order + var nodesProjected = await _projectionRepository.UpsertNodesAsync( + scanId, + document.Nodes, + cancellationToken).ConfigureAwait(false); + + // Step 3: Project edges in stable order + var edgesProjected = await _projectionRepository.UpsertEdgesAsync( + scanId, + document.Edges, + cancellationToken).ConfigureAwait(false); + + // Step 4: Project entrypoints in stable order + var entrypointsProjected = 0; + if (document.Entrypoints is { Count: > 0 }) + { + entrypointsProjected = await _projectionRepository.UpsertEntrypointsAsync( + scanId, + document.Entrypoints, + cancellationToken).ConfigureAwait(false); + } + + // Step 5: Mark scan as completed + await _projectionRepository.CompleteScanAsync(scanId, cancellationToken).ConfigureAwait(false); + + stopwatch.Stop(); + + _logger.LogInformation( + "Completed callgraph projection for scan {ScanId}: nodes={NodesProjected}, edges={EdgesProjected}, entrypoints={EntrypointsProjected}, duration={DurationMs}ms", + scanId, nodesProjected, edgesProjected, entrypointsProjected, stopwatch.ElapsedMilliseconds); + + return new CallGraphSyncResult( + ScanId: scanId, + NodesProjected: nodesProjected, + EdgesProjected: edgesProjected, + EntrypointsProjected: entrypointsProjected, + WasUpdated: nodesProjected > 0 || edgesProjected > 0, + DurationMs: stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError( + ex, + "Failed callgraph projection for scan {ScanId} after {DurationMs}ms: {ErrorMessage}", + scanId, stopwatch.ElapsedMilliseconds, ex.Message); + + await _projectionRepository.FailScanAsync(scanId, ex.Message, cancellationToken).ConfigureAwait(false); + + throw; + } + } + + /// + public async Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Deleting callgraph projection for scan {ScanId}", scanId); + + await _projectionRepository.DeleteScanAsync(scanId, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Deleted callgraph projection for scan {ScanId}", scanId); + } +} diff --git a/src/Signals/StellaOps.Signals/Services/CallgraphIngestionService.cs b/src/Signals/StellaOps.Signals/Services/CallgraphIngestionService.cs index 9685f004..434180a0 100644 --- a/src/Signals/StellaOps.Signals/Services/CallgraphIngestionService.cs +++ b/src/Signals/StellaOps.Signals/Services/CallgraphIngestionService.cs @@ -32,6 +32,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService private readonly ICallgraphRepository repository; private readonly IReachabilityStoreRepository reachabilityStore; private readonly ICallgraphNormalizationService normalizer; + private readonly ICallGraphSyncService callGraphSyncService; private readonly ILogger logger; private readonly SignalsOptions options; private readonly TimeProvider timeProvider; @@ -43,6 +44,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService ICallgraphRepository repository, IReachabilityStoreRepository reachabilityStore, ICallgraphNormalizationService normalizer, + ICallGraphSyncService callGraphSyncService, IOptions options, TimeProvider timeProvider, ILogger logger) @@ -52,6 +54,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); this.reachabilityStore = reachabilityStore ?? throw new ArgumentNullException(nameof(reachabilityStore)); this.normalizer = normalizer ?? throw new ArgumentNullException(nameof(normalizer)); + this.callGraphSyncService = callGraphSyncService ?? throw new ArgumentNullException(nameof(callGraphSyncService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); @@ -161,6 +164,38 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService document.Edges, cancellationToken).ConfigureAwait(false); + // Project the callgraph into relational tables for cross-artifact queries + // This is triggered post-upsert per SPRINT_3104 requirements + var scanId = Guid.TryParse(document.Id, out var parsedScanId) + ? parsedScanId + : Guid.NewGuid(); + var artifactDigest = document.Artifact.Hash ?? document.GraphHash ?? document.Id; + + try + { + var syncResult = await callGraphSyncService.SyncAsync( + scanId, + artifactDigest, + document, + cancellationToken).ConfigureAwait(false); + + logger.LogDebug( + "Projected callgraph {Id} to relational tables: nodes={NodesProjected}, edges={EdgesProjected}, entrypoints={EntrypointsProjected}, duration={DurationMs}ms", + document.Id, + syncResult.NodesProjected, + syncResult.EdgesProjected, + syncResult.EntrypointsProjected, + syncResult.DurationMs); + } + catch (Exception ex) + { + // Log but don't fail the ingest - projection is a secondary operation + logger.LogWarning( + ex, + "Failed to project callgraph {Id} to relational tables. The JSONB document was persisted successfully.", + document.Id); + } + logger.LogInformation( "Ingested callgraph {Language}:{Component}:{Version} (id={Id}) with {NodeCount} nodes and {EdgeCount} edges.", document.Language, diff --git a/src/Signals/StellaOps.Signals/Services/ICallGraphSyncService.cs b/src/Signals/StellaOps.Signals/Services/ICallGraphSyncService.cs new file mode 100644 index 00000000..5e17aad6 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/ICallGraphSyncService.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Services; + +/// +/// Synchronizes canonical callgraph documents to relational tables. +/// Enables cross-artifact queries, analytics, and efficient lookups. +/// +/// +/// This service projects the JSONB into +/// the relational tables defined in signals.* schema (cg_nodes, cg_edges, +/// entrypoints, etc.) for efficient querying. +/// +public interface ICallGraphSyncService +{ + /// + /// Projects a callgraph document into relational tables. + /// This operation is idempotent—repeated calls with the same + /// document will not create duplicates. + /// + /// The scan identifier. + /// The artifact digest for the scan context. + /// The callgraph document to project. + /// Cancellation token. + /// A result indicating projection status and statistics. + Task SyncAsync( + Guid scanId, + string artifactDigest, + CallgraphDocument document, + CancellationToken cancellationToken = default); + + /// + /// Removes all relational data for a given scan. + /// Used for cleanup or re-projection. + /// + /// The scan identifier to clean up. + /// Cancellation token. + Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default); +} + +/// +/// Result of a call graph sync operation. +/// +/// The scan identifier. +/// Number of nodes projected. +/// Number of edges projected. +/// Number of entrypoints projected. +/// True if any data was inserted/updated. +/// Duration of the sync operation in milliseconds. +public sealed record CallGraphSyncResult( + Guid ScanId, + int NodesProjected, + int EdgesProjected, + int EntrypointsProjected, + bool WasUpdated, + long DurationMs); diff --git a/src/Signals/StellaOps.Signals/Services/IScoreExplanationService.cs b/src/Signals/StellaOps.Signals/Services/IScoreExplanationService.cs new file mode 100644 index 00000000..c31c017e --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/IScoreExplanationService.cs @@ -0,0 +1,92 @@ +// ----------------------------------------------------------------------------- +// IScoreExplanationService.cs +// Sprint: SPRINT_3800_0001_0002_score_explanation_service +// Description: Interface for computing additive score explanations. +// ----------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Services; + +/// +/// Service for computing additive score explanations. +/// Transforms reachability data, CVSS scores, and gate information into +/// human-readable score contributions. +/// +public interface IScoreExplanationService +{ + /// + /// Computes a score explanation for a reachability fact. + /// + /// The score explanation request containing all input data. + /// Cancellation token. + /// A score explanation with contributions summing to the risk score. + Task ComputeExplanationAsync( + ScoreExplanationRequest request, + CancellationToken cancellationToken = default); + + /// + /// Computes a score explanation synchronously. + /// + /// The score explanation request. + /// A score explanation with contributions. + ScoreExplanation ComputeExplanation(ScoreExplanationRequest request); +} + +/// +/// Request for computing a score explanation. +/// +public sealed record ScoreExplanationRequest +{ + /// + /// CVE identifier. + /// + public string? CveId { get; init; } + + /// + /// CVSS v4 base score (0.0-10.0). + /// + public double? CvssScore { get; init; } + + /// + /// EPSS probability (0.0-1.0). + /// + public double? EpssScore { get; init; } + + /// + /// Reachability bucket (entrypoint, direct, runtime, unknown, unreachable). + /// + public string? ReachabilityBucket { get; init; } + + /// + /// Entrypoint type (http, grpc, cli, internal). + /// + public string? EntrypointType { get; init; } + + /// + /// Detected gates protecting the path. + /// + public IReadOnlyList? Gates { get; init; } + + /// + /// Whether the vulnerability is in the KEV list. + /// + public bool IsKnownExploited { get; init; } + + /// + /// Whether the path is internet-facing. + /// + public bool? IsInternetFacing { get; init; } + + /// + /// VEX status if available. + /// + public string? VexStatus { get; init; } + + /// + /// Reference to the evidence source (scan ID, graph hash, etc.). + /// + public string? EvidenceRef { get; init; } +} diff --git a/src/Signals/StellaOps.Signals/Services/ScoreExplanationService.cs b/src/Signals/StellaOps.Signals/Services/ScoreExplanationService.cs new file mode 100644 index 00000000..3d2b4f95 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/ScoreExplanationService.cs @@ -0,0 +1,315 @@ +// ----------------------------------------------------------------------------- +// ScoreExplanationService.cs +// Sprint: SPRINT_3800_0001_0002_score_explanation_service +// Description: Implementation of additive score explanation computation. +// ----------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Signals.Models; +using StellaOps.Signals.Options; + +namespace StellaOps.Signals.Services; + +/// +/// Computes additive score explanations for vulnerability findings. +/// The score is computed as a sum of weighted factors, each with a human-readable explanation. +/// +public sealed class ScoreExplanationService : IScoreExplanationService +{ + private readonly IOptions _options; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public ScoreExplanationService( + IOptions options, + ILogger logger, + TimeProvider? timeProvider = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public Task ComputeExplanationAsync( + ScoreExplanationRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(ComputeExplanation(request)); + } + + /// + public ScoreExplanation ComputeExplanation(ScoreExplanationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var weights = _options.Value.ExplanationWeights; + var contributions = new List(); + var modifiers = new List(); + double runningTotal = 0.0; + + // 1. CVSS Base Score Contribution + if (request.CvssScore.HasValue) + { + var cvssContribution = request.CvssScore.Value * weights.CvssMultiplier; + contributions.Add(new ScoreContribution + { + Factor = ScoreFactors.CvssBase, + Weight = weights.CvssMultiplier, + RawValue = request.CvssScore.Value, + Contribution = cvssContribution, + Explanation = $"CVSS base score {request.CvssScore.Value:F1} × {weights.CvssMultiplier:F1} weight", + Source = "nvd" + }); + runningTotal += cvssContribution; + } + + // 2. EPSS Contribution + if (request.EpssScore.HasValue) + { + var epssContribution = request.EpssScore.Value * weights.EpssMultiplier; + contributions.Add(new ScoreContribution + { + Factor = ScoreFactors.Epss, + Weight = weights.EpssMultiplier, + RawValue = request.EpssScore.Value, + Contribution = epssContribution, + Explanation = $"EPSS probability {request.EpssScore.Value:P1} indicates exploitation likelihood", + Source = "first" + }); + runningTotal += epssContribution; + } + + // 3. Reachability Contribution + if (!string.IsNullOrEmpty(request.ReachabilityBucket)) + { + var (reachabilityContribution, reachabilityExplanation) = ComputeReachabilityContribution( + request.ReachabilityBucket, weights); + + contributions.Add(new ScoreContribution + { + Factor = ScoreFactors.Reachability, + Weight = 1.0, + RawValue = reachabilityContribution, + Contribution = reachabilityContribution, + Explanation = reachabilityExplanation, + Source = "scan" + }); + runningTotal += reachabilityContribution; + } + + // 4. Exposure Surface Contribution + if (!string.IsNullOrEmpty(request.EntrypointType)) + { + var (exposureContribution, exposureExplanation) = ComputeExposureContribution( + request.EntrypointType, request.IsInternetFacing, weights); + + contributions.Add(new ScoreContribution + { + Factor = ScoreFactors.ExposureSurface, + Weight = 1.0, + RawValue = exposureContribution, + Contribution = exposureContribution, + Explanation = exposureExplanation, + Source = "scan" + }); + runningTotal += exposureContribution; + } + + // 5. Gate Multipliers (Discounts) + if (request.Gates is { Count: > 0 }) + { + var (gateDiscount, gateExplanation) = ComputeGateDiscounts(request.Gates, weights); + + if (gateDiscount != 0) + { + contributions.Add(new ScoreContribution + { + Factor = ScoreFactors.GateMultiplier, + Weight = 1.0, + RawValue = gateDiscount, + Contribution = gateDiscount, + Explanation = gateExplanation, + Source = "scan" + }); + runningTotal += gateDiscount; + } + } + + // 6. Known Exploitation Bonus + if (request.IsKnownExploited) + { + contributions.Add(new ScoreContribution + { + Factor = ScoreFactors.KnownExploitation, + Weight = 1.0, + RawValue = weights.KevBonus, + Contribution = weights.KevBonus, + Explanation = "Vulnerability is in CISA KEV list (known exploited)", + Source = "cisa_kev" + }); + runningTotal += weights.KevBonus; + } + + // 7. VEX Override (if not_affected, reduce to near-zero) + if (string.Equals(request.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase)) + { + var vexReduction = -(runningTotal * 0.9); // Reduce by 90% + modifiers.Add(new ScoreModifier + { + Type = "vex_reduction", + Before = runningTotal, + After = runningTotal + vexReduction, + Reason = "VEX statement indicates vulnerability is not exploitable in this context", + PolicyRef = "vex:not_affected" + }); + runningTotal += vexReduction; + } + + // Apply floor/ceiling + var originalTotal = runningTotal; + runningTotal = Math.Clamp(runningTotal, weights.MinScore, weights.MaxScore); + + if (runningTotal != originalTotal) + { + modifiers.Add(new ScoreModifier + { + Type = runningTotal < originalTotal ? "cap" : "floor", + Before = originalTotal, + After = runningTotal, + Reason = $"Score clamped to {weights.MinScore:F0}-{weights.MaxScore:F0} range" + }); + } + + _logger.LogDebug( + "Computed score explanation: {Score:F2} with {ContributionCount} contributions for {CveId}", + runningTotal, contributions.Count, request.CveId ?? "unknown"); + + return new ScoreExplanation + { + Kind = "stellaops_risk_v1", + RiskScore = runningTotal, + Contributions = contributions, + LastSeen = _timeProvider.GetUtcNow(), + AlgorithmVersion = "1.0.0", + EvidenceRef = request.EvidenceRef, + Summary = GenerateSummary(runningTotal, contributions), + Modifiers = modifiers.Count > 0 ? modifiers : null + }; + } + + private static (double contribution, string explanation) ComputeReachabilityContribution( + string bucket, ScoreExplanationWeights weights) + { + return bucket.ToLowerInvariant() switch + { + "entrypoint" => (weights.EntrypointReachability, + "Vulnerable code is directly reachable from application entrypoint"), + "direct" => (weights.DirectReachability, + "Vulnerable code is directly called from application code"), + "runtime" => (weights.RuntimeReachability, + "Vulnerable code execution observed at runtime"), + "unknown" => (weights.UnknownReachability, + "Reachability could not be determined; assuming partial exposure"), + "unreachable" => (weights.UnreachableReachability, + "No path found from entrypoints to vulnerable code"), + _ => (weights.UnknownReachability, + $"Unknown reachability bucket '{bucket}'; assuming partial exposure") + }; + } + + private static (double contribution, string explanation) ComputeExposureContribution( + string entrypointType, bool? isInternetFacing, ScoreExplanationWeights weights) + { + var baseContribution = entrypointType.ToLowerInvariant() switch + { + "http" or "https" or "http_handler" => weights.HttpExposure, + "grpc" or "grpc_method" => weights.GrpcExposure, + "cli" or "cli_command" or "scheduled" => weights.CliExposure, + "internal" or "library" => weights.InternalExposure, + _ => weights.InternalExposure + }; + + var exposureType = entrypointType.ToLowerInvariant() switch + { + "http" or "https" or "http_handler" => "HTTP/HTTPS", + "grpc" or "grpc_method" => "gRPC", + "cli" or "cli_command" => "CLI", + "scheduled" => "scheduled task", + "internal" or "library" => "internal", + _ => entrypointType + }; + + var internetSuffix = isInternetFacing == true ? " (internet-facing)" : ""; + return (baseContribution, $"Exposed via {exposureType} entrypoint{internetSuffix}"); + } + + private static (double discount, string explanation) ComputeGateDiscounts( + IReadOnlyList gates, ScoreExplanationWeights weights) + { + double totalDiscount = 0; + var gateDescriptions = new List(); + + foreach (var gate in gates) + { + var normalizedGate = gate.ToLowerInvariant(); + + if (normalizedGate.Contains("auth") || normalizedGate.Contains("authorize")) + { + totalDiscount += weights.AuthGateDiscount; + gateDescriptions.Add("authentication required"); + } + else if (normalizedGate.Contains("admin") || normalizedGate.Contains("role")) + { + totalDiscount += weights.AdminGateDiscount; + gateDescriptions.Add("admin/role restriction"); + } + else if (normalizedGate.Contains("feature") || normalizedGate.Contains("flag")) + { + totalDiscount += weights.FeatureFlagDiscount; + gateDescriptions.Add("feature flag protection"); + } + else if (normalizedGate.Contains("config") || normalizedGate.Contains("default")) + { + totalDiscount += weights.NonDefaultConfigDiscount; + gateDescriptions.Add("non-default configuration"); + } + } + + if (gateDescriptions.Count == 0) + { + return (0, "No protective gates detected"); + } + + return (totalDiscount, $"Protected by: {string.Join(", ", gateDescriptions)}"); + } + + private static string GenerateSummary(double score, IReadOnlyList contributions) + { + var severity = score switch + { + >= 80 => "Critical", + >= 60 => "High", + >= 40 => "Medium", + >= 20 => "Low", + _ => "Minimal" + }; + + var topFactors = contributions + .OrderByDescending(c => Math.Abs(c.Contribution)) + .Take(2) + .Select(c => c.Factor) + .ToList(); + + var factorSummary = topFactors.Count > 0 + ? $" driven by {string.Join(" and ", topFactors)}" + : ""; + + return $"{severity} risk ({score:F0}/100){factorSummary}"; + } +} diff --git a/src/Signals/StellaOps.Signals/TASKS.md b/src/Signals/StellaOps.Signals/TASKS.md index fc339ce6..b3b21799 100644 --- a/src/Signals/StellaOps.Signals/TASKS.md +++ b/src/Signals/StellaOps.Signals/TASKS.md @@ -12,3 +12,7 @@ This file mirrors sprint work for the Signals module. | `GATE-3405-011` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Applied gate multipliers in `ReachabilityScoringService` using path gate evidence from callgraph edges. | | `GATE-3405-012` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Extended reachability fact evidence contract + digest to include `GateMultiplierBps` and `Gates`. | | `GATE-3405-016` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Added deterministic parser/normalizer/scoring coverage for gate propagation + multiplier effect. | +| `SIG-CG-3104-001` | `docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Defined `ICallGraphSyncService` contract for projecting callgraphs into relational tables. | +| `SIG-CG-3104-002` | `docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Implemented `CallGraphSyncService` with idempotent, transactional batch projection. | +| `SIG-CG-3104-003` | `docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Wired projection trigger in `CallgraphIngestionService` post-upsert. | +| `SIG-CG-3104-004` | `docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Added unit tests (`CallGraphSyncServiceTests.cs`) and integration tests (`CallGraphProjectionIntegrationTests.cs`). | diff --git a/src/Signals/__Tests/StellaOps.Signals.Tests/CallGraphSyncServiceTests.cs b/src/Signals/__Tests/StellaOps.Signals.Tests/CallGraphSyncServiceTests.cs new file mode 100644 index 00000000..37d66ec0 --- /dev/null +++ b/src/Signals/__Tests/StellaOps.Signals.Tests/CallGraphSyncServiceTests.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.Signals.Models; +using StellaOps.Signals.Persistence; +using StellaOps.Signals.Services; +using Xunit; + +namespace StellaOps.Signals.Tests; + +/// +/// Unit tests for . +/// +public sealed class CallGraphSyncServiceTests +{ + [Fact] + public async Task SyncAsync_WithValidDocument_ReturnsSuccessResult() + { + // Arrange + var repository = new InMemoryCallGraphProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + + // Act + var result = await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert + Assert.Equal(scanId, result.ScanId); + Assert.Equal(3, result.NodesProjected); + Assert.Equal(2, result.EdgesProjected); + Assert.Equal(1, result.EntrypointsProjected); + Assert.True(result.WasUpdated); + Assert.True(result.DurationMs >= 0); + } + + [Fact] + public async Task SyncAsync_ProjectsToRepository() + { + // Arrange + var repository = new InMemoryCallGraphProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + + // Act + await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert - check repository state + Assert.Single(repository.Scans); + Assert.Equal(3, repository.Nodes.Count); + Assert.Equal(2, repository.Edges.Count); + Assert.Single(repository.Entrypoints); + } + + [Fact] + public async Task SyncAsync_SetsScanStatusToCompleted() + { + // Arrange + var repository = new InMemoryCallGraphProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + + // Act + await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert + Assert.True(repository.Scans.ContainsKey(scanId)); + Assert.Equal("completed", repository.Scans[scanId].Status); + } + + [Fact] + public async Task SyncAsync_WithEmptyDocument_ReturnsZeroCounts() + { + // Arrange + var repository = new InMemoryCallGraphProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = new CallgraphDocument + { + Id = Guid.NewGuid().ToString("N"), + Language = "csharp", + GraphHash = "test-hash", + Nodes = new List(), + Edges = new List(), + Entrypoints = new List() + }; + + // Act + var result = await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert + Assert.Equal(0, result.NodesProjected); + Assert.Equal(0, result.EdgesProjected); + Assert.Equal(0, result.EntrypointsProjected); + Assert.False(result.WasUpdated); + } + + [Fact] + public async Task SyncAsync_WithNullDocument_ThrowsArgumentNullException() + { + // Arrange + var repository = new InMemoryCallGraphProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + // Act & Assert + await Assert.ThrowsAsync(() => + service.SyncAsync(Guid.NewGuid(), "sha256:test-digest", null!)); + } + + [Fact] + public async Task SyncAsync_WithEmptyArtifactDigest_ThrowsArgumentException() + { + // Arrange + var repository = new InMemoryCallGraphProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var document = CreateSampleDocument(); + + // Act & Assert + await Assert.ThrowsAsync(() => + service.SyncAsync(Guid.NewGuid(), "", document)); + } + + [Fact] + public async Task DeleteByScanAsync_RemovesScanFromRepository() + { + // Arrange + var repository = new InMemoryCallGraphProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = CreateSampleDocument(); + await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Act + await service.DeleteByScanAsync(scanId); + + // Assert + Assert.Empty(repository.Scans); + Assert.Empty(repository.Nodes); + Assert.Empty(repository.Edges); + Assert.Empty(repository.Entrypoints); + } + + [Fact] + public async Task SyncAsync_OrdersNodesAndEdgesDeterministically() + { + // Arrange + var repository = new TrackingProjectionRepository(); + var service = new CallGraphSyncService( + repository, + TimeProvider.System, + NullLogger.Instance); + + var scanId = Guid.NewGuid(); + var document = new CallgraphDocument + { + Id = Guid.NewGuid().ToString("N"), + Language = "csharp", + GraphHash = "test-hash", + Nodes = new List + { + new() { Id = "z-node", Name = "Last" }, + new() { Id = "a-node", Name = "First" }, + new() { Id = "m-node", Name = "Middle" } + }, + Edges = new List + { + new() { SourceId = "z-node", TargetId = "a-node" }, + new() { SourceId = "a-node", TargetId = "m-node" } + }, + Entrypoints = new List() + }; + + // Act + await service.SyncAsync(scanId, "sha256:test-digest", document); + + // Assert - nodes should be processed in sorted order by Id + Assert.Equal(3, repository.ProjectedNodes.Count); + Assert.Equal("a-node", repository.ProjectedNodes[0].Id); + Assert.Equal("m-node", repository.ProjectedNodes[1].Id); + Assert.Equal("z-node", repository.ProjectedNodes[2].Id); + } + + private static CallgraphDocument CreateSampleDocument() + { + return new CallgraphDocument + { + Id = Guid.NewGuid().ToString("N"), + Language = "csharp", + GraphHash = "sha256:sample-graph-hash", + Nodes = new List + { + new() { Id = "node-1", Name = "Main", Kind = "method", Namespace = "Program", Visibility = SymbolVisibility.Public, IsEntrypointCandidate = true }, + new() { Id = "node-2", Name = "DoWork", Kind = "method", Namespace = "Service", Visibility = SymbolVisibility.Internal }, + new() { Id = "node-3", Name = "ProcessData", Kind = "method", Namespace = "Core", Visibility = SymbolVisibility.Private } + }, + Edges = new List + { + new() { SourceId = "node-1", TargetId = "node-2", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 }, + new() { SourceId = "node-2", TargetId = "node-3", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 } + }, + Entrypoints = new List + { + new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 } + } + }; + } + + /// + /// Test repository that tracks the order of projected nodes. + /// + private sealed class TrackingProjectionRepository : ICallGraphProjectionRepository + { + public List ProjectedNodes { get; } = new(); + + public Task UpsertScanAsync(Guid scanId, string artifactDigest, string? sbomDigest = null, string? repoUri = null, string? commitSha = null, CancellationToken cancellationToken = default) + => Task.FromResult(true); + + public Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task UpsertNodesAsync(Guid scanId, IReadOnlyList nodes, CancellationToken cancellationToken = default) + { + // Store in the order received - the service should have sorted them + ProjectedNodes.AddRange(nodes); + return Task.FromResult(nodes.Count); + } + + public Task UpsertEdgesAsync(Guid scanId, IReadOnlyList edges, CancellationToken cancellationToken = default) + => Task.FromResult(edges.Count); + + public Task UpsertEntrypointsAsync(Guid scanId, IReadOnlyList entrypoints, CancellationToken cancellationToken = default) + => Task.FromResult(entrypoints.Count); + + public Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } +} diff --git a/src/Signals/__Tests/StellaOps.Signals.Tests/CallgraphIngestionServiceTests.cs b/src/Signals/__Tests/StellaOps.Signals.Tests/CallgraphIngestionServiceTests.cs index 55ad4c79..e39be3f3 100644 --- a/src/Signals/__Tests/StellaOps.Signals.Tests/CallgraphIngestionServiceTests.cs +++ b/src/Signals/__Tests/StellaOps.Signals.Tests/CallgraphIngestionServiceTests.cs @@ -33,12 +33,14 @@ public class CallgraphIngestionServiceTests var resolver = new StubParserResolver(parser); var options = Microsoft.Extensions.Options.Options.Create(new SignalsOptions()); var reachabilityStore = new InMemoryReachabilityStoreRepository(_timeProvider); + var callGraphSyncService = new StubCallGraphSyncService(); var service = new CallgraphIngestionService( resolver, _artifactStore, _repository, reachabilityStore, _normalizer, + callGraphSyncService, options, _timeProvider, NullLogger.Instance); @@ -189,4 +191,33 @@ public class CallgraphIngestionServiceTests return Task.FromResult(document); } } + + private sealed class StubCallGraphSyncService : ICallGraphSyncService + { + public CallGraphSyncResult? LastSyncResult { get; private set; } + public CallgraphDocument? LastSyncedDocument { get; private set; } + + public Task SyncAsync( + Guid scanId, + string artifactDigest, + CallgraphDocument document, + CancellationToken cancellationToken = default) + { + LastSyncedDocument = document; + var result = new CallGraphSyncResult( + ScanId: scanId, + NodesProjected: document.Nodes.Count, + EdgesProjected: document.Edges.Count, + EntrypointsProjected: document.Entrypoints.Count, + WasUpdated: true, + DurationMs: 1); + LastSyncResult = result; + return Task.FromResult(result); + } + + public Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } } diff --git a/src/Signals/__Tests/StellaOps.Signals.Tests/ScoreExplanationServiceTests.cs b/src/Signals/__Tests/StellaOps.Signals.Tests/ScoreExplanationServiceTests.cs new file mode 100644 index 00000000..3861a28b --- /dev/null +++ b/src/Signals/__Tests/StellaOps.Signals.Tests/ScoreExplanationServiceTests.cs @@ -0,0 +1,287 @@ +// ----------------------------------------------------------------------------- +// ScoreExplanationServiceTests.cs +// Sprint: SPRINT_3800_0001_0002_score_explanation_service +// Description: Unit tests for ScoreExplanationService. +// ----------------------------------------------------------------------------- + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Signals.Models; +using StellaOps.Signals.Options; +using StellaOps.Signals.Services; +using Xunit; + +namespace StellaOps.Signals.Tests; + +public class ScoreExplanationServiceTests +{ + private readonly ScoreExplanationService _service; + private readonly SignalsScoringOptions _options; + + public ScoreExplanationServiceTests() + { + _options = new SignalsScoringOptions(); + _service = new ScoreExplanationService( + Options.Create(_options), + NullLogger.Instance); + } + + [Fact] + public void ComputeExplanation_WithCvssOnly_ReturnsCorrectContribution() + { + var request = new ScoreExplanationRequest + { + CveId = "CVE-2021-44228", + CvssScore = 10.0 + }; + + var result = _service.ComputeExplanation(request); + + Assert.Equal("stellaops_risk_v1", result.Kind); + Assert.Single(result.Contributions); + + var cvssContrib = result.Contributions[0]; + Assert.Equal(ScoreFactors.CvssBase, cvssContrib.Factor); + Assert.Equal(10.0, cvssContrib.RawValue); + Assert.Equal(50.0, cvssContrib.Contribution); // 10.0 * 5.0 default multiplier + Assert.Equal(50.0, result.RiskScore); + } + + [Fact] + public void ComputeExplanation_WithEpss_ReturnsCorrectContribution() + { + var request = new ScoreExplanationRequest + { + CveId = "CVE-2023-12345", + EpssScore = 0.5 // 50% probability + }; + + var result = _service.ComputeExplanation(request); + + var epssContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.Epss); + Assert.Equal(0.5, epssContrib.RawValue); + Assert.Equal(5.0, epssContrib.Contribution); // 0.5 * 10.0 default multiplier + } + + [Theory] + [InlineData("entrypoint", 25.0)] + [InlineData("direct", 20.0)] + [InlineData("runtime", 22.0)] + [InlineData("unknown", 12.0)] + [InlineData("unreachable", 0.0)] + public void ComputeExplanation_WithReachabilityBucket_ReturnsCorrectContribution( + string bucket, double expectedContribution) + { + var request = new ScoreExplanationRequest + { + ReachabilityBucket = bucket + }; + + var result = _service.ComputeExplanation(request); + + var reachContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.Reachability); + Assert.Equal(expectedContribution, reachContrib.Contribution); + } + + [Theory] + [InlineData("http", 15.0)] + [InlineData("https", 15.0)] + [InlineData("http_handler", 15.0)] + [InlineData("grpc", 12.0)] + [InlineData("cli", 3.0)] + [InlineData("internal", 5.0)] + public void ComputeExplanation_WithEntrypointType_ReturnsCorrectExposure( + string entrypointType, double expectedContribution) + { + var request = new ScoreExplanationRequest + { + EntrypointType = entrypointType + }; + + var result = _service.ComputeExplanation(request); + + var exposureContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.ExposureSurface); + Assert.Equal(expectedContribution, exposureContrib.Contribution); + } + + [Fact] + public void ComputeExplanation_WithAuthGate_AppliesDiscount() + { + var request = new ScoreExplanationRequest + { + CvssScore = 8.0, + Gates = new[] { "auth_required" } + }; + + var result = _service.ComputeExplanation(request); + + var gateContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.GateMultiplier); + Assert.Equal(-3.0, gateContrib.Contribution); // Default auth discount + Assert.Equal(37.0, result.RiskScore); // 8.0 * 5.0 - 3.0 + } + + [Fact] + public void ComputeExplanation_WithMultipleGates_CombinesDiscounts() + { + var request = new ScoreExplanationRequest + { + CvssScore = 10.0, + Gates = new[] { "auth_required", "admin_role", "feature_flag" } + }; + + var result = _service.ComputeExplanation(request); + + var gateContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.GateMultiplier); + // auth: -3, admin: -5, feature_flag: -2 = -10 total + Assert.Equal(-10.0, gateContrib.Contribution); + Assert.Equal(40.0, result.RiskScore); // 50 - 10 + } + + [Fact] + public void ComputeExplanation_WithKev_AppliesBonus() + { + var request = new ScoreExplanationRequest + { + CvssScore = 7.0, + IsKnownExploited = true + }; + + var result = _service.ComputeExplanation(request); + + var kevContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.KnownExploitation); + Assert.Equal(10.0, kevContrib.Contribution); + Assert.Equal(45.0, result.RiskScore); // 7.0 * 5.0 + 10.0 + } + + [Fact] + public void ComputeExplanation_WithVexNotAffected_ReducesScore() + { + var request = new ScoreExplanationRequest + { + CvssScore = 10.0, + VexStatus = "not_affected" + }; + + var result = _service.ComputeExplanation(request); + + Assert.NotNull(result.Modifiers); + Assert.Contains(result.Modifiers, m => m.Type == "vex_reduction"); + Assert.True(result.RiskScore < 50.0); // Should be significantly reduced + } + + [Fact] + public void ComputeExplanation_ClampsToMaxScore() + { + var request = new ScoreExplanationRequest + { + CvssScore = 10.0, + EpssScore = 0.95, + ReachabilityBucket = "entrypoint", + EntrypointType = "http", + IsKnownExploited = true + }; + + var result = _service.ComputeExplanation(request); + + Assert.Equal(100.0, result.RiskScore); // Clamped to max + Assert.NotNull(result.Modifiers); + Assert.Contains(result.Modifiers, m => m.Type == "cap"); + } + + [Fact] + public void ComputeExplanation_ContributionsSumToTotal() + { + var request = new ScoreExplanationRequest + { + CvssScore = 8.5, + EpssScore = 0.3, + ReachabilityBucket = "direct", + EntrypointType = "grpc" + }; + + var result = _service.ComputeExplanation(request); + + var expectedSum = result.Contributions.Sum(c => c.Contribution); + Assert.Equal(expectedSum, result.RiskScore, precision: 5); + } + + [Fact] + public void ComputeExplanation_GeneratesSummary() + { + var request = new ScoreExplanationRequest + { + CvssScore = 9.8, + ReachabilityBucket = "entrypoint" + }; + + var result = _service.ComputeExplanation(request); + + Assert.NotNull(result.Summary); + Assert.Contains("risk", result.Summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ComputeExplanation_SetsAlgorithmVersion() + { + var request = new ScoreExplanationRequest { CvssScore = 5.0 }; + + var result = _service.ComputeExplanation(request); + + Assert.Equal("1.0.0", result.AlgorithmVersion); + } + + [Fact] + public void ComputeExplanation_PreservesEvidenceRef() + { + var request = new ScoreExplanationRequest + { + CvssScore = 5.0, + EvidenceRef = "scan:abc123" + }; + + var result = _service.ComputeExplanation(request); + + Assert.Equal("scan:abc123", result.EvidenceRef); + } + + [Fact] + public async Task ComputeExplanationAsync_ReturnsSameAsSync() + { + var request = new ScoreExplanationRequest + { + CvssScore = 7.5, + ReachabilityBucket = "runtime" + }; + + var syncResult = _service.ComputeExplanation(request); + var asyncResult = await _service.ComputeExplanationAsync(request); + + Assert.Equal(syncResult.RiskScore, asyncResult.RiskScore); + Assert.Equal(syncResult.Contributions.Count, asyncResult.Contributions.Count); + } + + [Fact] + public void ComputeExplanation_IsDeterministic() + { + var request = new ScoreExplanationRequest + { + CvssScore = 8.0, + EpssScore = 0.4, + ReachabilityBucket = "entrypoint", + EntrypointType = "http", + Gates = new[] { "auth_required" } + }; + + var result1 = _service.ComputeExplanation(request); + var result2 = _service.ComputeExplanation(request); + + Assert.Equal(result1.RiskScore, result2.RiskScore); + Assert.Equal(result1.Contributions.Count, result2.Contributions.Count); + + for (int i = 0; i < result1.Contributions.Count; i++) + { + Assert.Equal(result1.Contributions[i].Factor, result2.Contributions[i].Factor); + Assert.Equal(result1.Contributions[i].Contribution, result2.Contributions[i].Contribution); + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts b/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts new file mode 100644 index 00000000..d73a60e5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts @@ -0,0 +1,312 @@ +/** + * Attestation Chain API Client + * Sprint: SPRINT_4100_0001_0001_triage_models + * Provides API client for verifying and fetching attestation chains. + */ + +import { Injectable, InjectionToken, inject, signal } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, of, map, shareReplay, catchError, throwError } from 'rxjs'; + +import { + AttestationChain, + AttestationNode, + AttestationVerifyRequest, + AttestationVerifyResult, + DsseEnvelope, + InTotoStatement, + RekorLogEntry, + SignerInfo, +} from './attestation-chain.models'; +import { TenantActivationService } from '../auth/tenant-activation.service'; +import { generateTraceId } from './trace.util'; + +/** + * Attestation Chain API interface. + */ +export interface AttestationChainApi { + /** Verify a DSSE envelope. */ + verify(request: AttestationVerifyRequest): Observable; + + /** Get attestation chain for a digest. */ + getChain(digest: string, options?: AttestationQueryOptions): Observable; + + /** Get single attestation node by ID. */ + getNode(nodeId: string, options?: AttestationQueryOptions): Observable; + + /** List attestations for a subject digest. */ + listBySubject( + subjectDigest: string, + options?: AttestationQueryOptions + ): Observable; + + /** Fetch Rekor log entry for an attestation. */ + getRekorEntry(uuid: string): Observable; + + /** Download raw DSSE envelope. */ + downloadEnvelope(nodeId: string): Observable; +} + +export interface AttestationQueryOptions { + readonly tenantId?: string; + readonly traceId?: string; + readonly include_rekor?: boolean; + readonly include_cert_chain?: boolean; +} + +export const ATTESTATION_CHAIN_API = new InjectionToken( + 'ATTESTATION_CHAIN_API' +); + +/** + * HTTP implementation of the Attestation Chain API. + */ +@Injectable() +export class AttestationChainHttpClient implements AttestationChainApi { + private readonly http = inject(HttpClient); + private readonly tenantService = inject(TenantActivationService, { optional: true }); + + private readonly baseUrl = signal('/api/v1/attestor'); + private readonly rekorUrl = signal('https://rekor.sigstore.dev'); + + // Cache for verified chains + private readonly chainCache = new Map>(); + private readonly cacheMaxAge = 300_000; // 5 minutes + + verify(request: AttestationVerifyRequest): Observable { + const url = `${this.baseUrl()}/verify`; + const headers = this.buildHeaders(); + + return this.http.post(url, request, { headers }).pipe( + catchError(this.handleError) + ); + } + + getChain(digest: string, options?: AttestationQueryOptions): Observable { + const cacheKey = `chain:${digest}`; + + if (this.chainCache.has(cacheKey)) { + return this.chainCache.get(cacheKey)!; + } + + const url = `${this.baseUrl()}/chains/${encodeURIComponent(digest)}`; + const params = this.buildParams(options); + const headers = this.buildHeaders(options); + + const request$ = this.http.get(url, { params, headers }).pipe( + shareReplay({ bufferSize: 1, refCount: true }), + catchError(this.handleError) + ); + + this.chainCache.set(cacheKey, request$); + setTimeout(() => this.chainCache.delete(cacheKey), this.cacheMaxAge); + + return request$; + } + + getNode(nodeId: string, options?: AttestationQueryOptions): Observable { + const url = `${this.baseUrl()}/nodes/${encodeURIComponent(nodeId)}`; + const params = this.buildParams(options); + const headers = this.buildHeaders(options); + + return this.http.get(url, { params, headers }).pipe( + catchError(this.handleError) + ); + } + + listBySubject( + subjectDigest: string, + options?: AttestationQueryOptions + ): Observable { + const url = `${this.baseUrl()}/subjects/${encodeURIComponent(subjectDigest)}/attestations`; + const params = this.buildParams(options); + const headers = this.buildHeaders(options); + + return this.http.get<{ items: AttestationNode[] }>(url, { params, headers }).pipe( + map((response) => response.items), + catchError(this.handleError) + ); + } + + getRekorEntry(uuid: string): Observable { + const url = `${this.rekorUrl()}/api/v1/log/entries/${encodeURIComponent(uuid)}`; + + return this.http.get>(url).pipe( + map((response) => this.parseRekorResponse(uuid, response)), + catchError(this.handleError) + ); + } + + downloadEnvelope(nodeId: string): Observable { + const url = `${this.baseUrl()}/nodes/${encodeURIComponent(nodeId)}/envelope`; + const headers = this.buildHeaders(); + + return this.http.get(url, { headers }).pipe(catchError(this.handleError)); + } + + /** + * Invalidate cached chain for a digest. + */ + invalidateCache(digest?: string): void { + if (digest) { + this.chainCache.delete(`chain:${digest}`); + } else { + this.chainCache.clear(); + } + } + + private parseRekorResponse(uuid: string, response: Record): RekorLogEntry { + // Rekor returns { uuid: { body, integratedTime, logIndex, ... } } + const entry = response[uuid] as Record; + + return { + uuid, + log_index: entry['logIndex'] as number, + log_id: entry['logID'] as string, + integrated_time: new Date((entry['integratedTime'] as number) * 1000).toISOString(), + signed_entry_timestamp: entry['verification'] as string, + inclusion_proof: entry['inclusionProof'] + ? { + log_index: (entry['inclusionProof'] as Record)['logIndex'] as number, + root_hash: (entry['inclusionProof'] as Record)['rootHash'] as string, + tree_size: (entry['inclusionProof'] as Record)['treeSize'] as number, + hashes: (entry['inclusionProof'] as Record)['hashes'] as string[], + } + : undefined, + }; + } + + private buildParams(options?: AttestationQueryOptions): HttpParams { + let params = new HttpParams(); + + if (options?.include_rekor) { + params = params.set('include_rekor', 'true'); + } + if (options?.include_cert_chain) { + params = params.set('include_cert_chain', 'true'); + } + + return params; + } + + private buildHeaders(options?: AttestationQueryOptions): Record { + const headers: Record = {}; + + const tenantId = options?.tenantId ?? this.tenantService?.activeTenantId(); + if (tenantId) { + headers['X-Tenant-Id'] = tenantId; + } + + const traceId = options?.traceId ?? generateTraceId(); + headers['X-Trace-Id'] = traceId; + + return headers; + } + + private handleError(error: unknown): Observable { + console.error('[AttestationChainClient] API error:', error); + return throwError(() => error); + } +} + +/** + * Mock implementation for testing and development. + */ +@Injectable() +export class AttestationChainMockClient implements AttestationChainApi { + private readonly mockChain: AttestationChain = { + chain_id: 'chain-mock-001', + nodes: [ + { + node_id: 'node-001', + type: 'sbom', + predicate_type: 'https://spdx.dev/Document', + subjects: [ + { + name: 'myapp:1.0.0', + digest: { sha256: 'abc123def456...' }, + }, + ], + signer: { + key_id: 'keyid:abc123', + identity: 'build@example.com', + algorithm: 'ecdsa-p256', + trusted: true, + }, + created_at: new Date().toISOString(), + }, + { + node_id: 'node-002', + type: 'scan', + predicate_type: 'https://stellaops.io/attestation/vuln-scan/v1', + subjects: [ + { + name: 'myapp:1.0.0', + digest: { sha256: 'abc123def456...' }, + }, + ], + signer: { + key_id: 'keyid:scanner001', + identity: 'scanner@stellaops.io', + algorithm: 'ecdsa-p256', + trusted: true, + }, + created_at: new Date().toISOString(), + parent_id: 'node-001', + }, + ], + status: 'verified', + verified_at: new Date().toISOString(), + rekor_entry: { + log_index: 12345678, + log_id: 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + uuid: 'mock-uuid-12345', + integrated_time: new Date().toISOString(), + }, + }; + + verify(request: AttestationVerifyRequest): Observable { + return of({ + valid: true, + status: 'verified' as const, + signer: { + key_id: 'keyid:mock', + identity: 'mock@example.com', + trusted: true, + }, + }); + } + + getChain(digest: string, options?: AttestationQueryOptions): Observable { + return of({ ...this.mockChain, chain_id: `chain:${digest}` }); + } + + getNode(nodeId: string, options?: AttestationQueryOptions): Observable { + const node = this.mockChain.nodes.find((n) => n.node_id === nodeId); + return node ? of(node) : throwError(() => new Error(`Node not found: ${nodeId}`)); + } + + listBySubject( + subjectDigest: string, + options?: AttestationQueryOptions + ): Observable { + return of(this.mockChain.nodes); + } + + getRekorEntry(uuid: string): Observable { + return of(this.mockChain.rekor_entry!); + } + + downloadEnvelope(nodeId: string): Observable { + return of({ + payloadType: 'YXBwbGljYXRpb24vdm5kLmluLXRvdG8ranNvbg==', // application/vnd.in-toto+json + payload: btoa(JSON.stringify({ _type: 'mock', subject: [], predicateType: 'mock' })), + signatures: [ + { + keyid: 'keyid:mock', + sig: 'mock-signature-base64', + }, + ], + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.models.ts b/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.models.ts new file mode 100644 index 00000000..8c688b91 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.models.ts @@ -0,0 +1,291 @@ +/** + * Attestation Chain Models + * Sprint: SPRINT_4100_0001_0001_triage_models + * DSSE (Dead Simple Signing Envelope) and in-toto model types. + */ + +// ============================================================================ +// DSSE Envelope Types +// ============================================================================ + +/** + * DSSE (Dead Simple Signing Envelope) structure. + * @see https://github.com/secure-systems-lab/dsse + */ +export interface DsseEnvelope { + /** Base64-encoded payload type URI. */ + readonly payloadType: string; + /** Base64-encoded payload. */ + readonly payload: string; + /** Array of signatures. */ + readonly signatures: readonly DsseSignature[]; +} + +/** + * DSSE signature structure. + */ +export interface DsseSignature { + /** Key identifier (fingerprint, URI, or key ID). */ + readonly keyid: string; + /** Base64-encoded signature. */ + readonly sig: string; +} + +// ============================================================================ +// in-toto Statement Types +// ============================================================================ + +/** + * in-toto Statement wrapper (v1.0). + * @see https://github.com/in-toto/attestation + */ +export interface InTotoStatement { + /** Schema version, should be "https://in-toto.io/Statement/v1". */ + readonly _type: string; + /** Subject artifacts this statement is about. */ + readonly subject: readonly InTotoSubject[]; + /** Predicate type URI. */ + readonly predicateType: string; + /** Predicate payload (type depends on predicateType). */ + readonly predicate: T; +} + +/** + * in-toto Subject (artifact reference). + */ +export interface InTotoSubject { + /** Artifact name or identifier. */ + readonly name: string; + /** Digest map (algorithm → hex value). */ + readonly digest: Record; +} + +// ============================================================================ +// Attestation Chain Types +// ============================================================================ + +/** + * Attestation chain representing linked evidence. + */ +export interface AttestationChain { + /** Chain identifier (root envelope digest). */ + readonly chain_id: string; + /** Ordered list of attestation nodes in the chain. */ + readonly nodes: readonly AttestationNode[]; + /** Chain verification status. */ + readonly status: AttestationChainStatus; + /** When the chain was verified. */ + readonly verified_at: string; + /** Rekor log entry if transparency-logged. */ + readonly rekor_entry?: RekorLogEntry; +} + +/** + * Single node in an attestation chain. + */ +export interface AttestationNode { + /** Node identifier (envelope digest). */ + readonly node_id: string; + /** Node type (sbom, scan, vex, policy, witness). */ + readonly type: AttestationNodeType; + /** Predicate type URI from the statement. */ + readonly predicate_type: string; + /** Subject digests this node attests. */ + readonly subjects: readonly InTotoSubject[]; + /** Key that signed this node. */ + readonly signer: SignerInfo; + /** When this attestation was created. */ + readonly created_at: string; + /** Parent node ID (for chain ordering). */ + readonly parent_id?: string; + /** Node-specific metadata. */ + readonly metadata?: Record; +} + +/** + * Attestation node types. + */ +export type AttestationNodeType = + | 'sbom' + | 'scan' + | 'vex' + | 'policy' + | 'witness' + | 'provenance' + | 'custom'; + +/** + * Signer information. + */ +export interface SignerInfo { + /** Key identifier. */ + readonly key_id: string; + /** Signer identity (email, URI, etc.). */ + readonly identity?: string; + /** Key algorithm (ecdsa-p256, ed25519, rsa-pss). */ + readonly algorithm?: string; + /** Whether the key is from a trusted root. */ + readonly trusted: boolean; + /** Certificate chain if using X.509. */ + readonly cert_chain?: readonly string[]; +} + +/** + * Chain verification status. + */ +export type AttestationChainStatus = + | 'verified' + | 'signature_invalid' + | 'chain_broken' + | 'expired' + | 'untrusted_signer' + | 'pending'; + +// ============================================================================ +// Rekor Integration +// ============================================================================ + +/** + * Rekor transparency log entry. + */ +export interface RekorLogEntry { + /** Log index. */ + readonly log_index: number; + /** Log ID (tree ID). */ + readonly log_id: string; + /** Entry UUID. */ + readonly uuid: string; + /** Integrated timestamp (RFC 3339). */ + readonly integrated_time: string; + /** Inclusion proof. */ + readonly inclusion_proof?: RekorInclusionProof; + /** Signed entry timestamp. */ + readonly signed_entry_timestamp?: string; +} + +/** + * Rekor Merkle tree inclusion proof. + */ +export interface RekorInclusionProof { + /** Log index. */ + readonly log_index: number; + /** Root hash. */ + readonly root_hash: string; + /** Tree size at time of inclusion. */ + readonly tree_size: number; + /** Merkle proof hashes. */ + readonly hashes: readonly string[]; +} + +// ============================================================================ +// Verification Types +// ============================================================================ + +/** + * Attestation verification request. + */ +export interface AttestationVerifyRequest { + /** DSSE envelope to verify. */ + readonly envelope: DsseEnvelope; + /** Expected predicate type (optional validation). */ + readonly expected_predicate_type?: string; + /** Whether to verify Rekor inclusion. */ + readonly verify_rekor?: boolean; + /** Trusted key IDs for signature verification. */ + readonly trusted_keys?: readonly string[]; +} + +/** + * Attestation verification result. + */ +export interface AttestationVerifyResult { + /** Whether verification succeeded. */ + readonly valid: boolean; + /** Verification status. */ + readonly status: AttestationChainStatus; + /** Parsed statement (if signature valid). */ + readonly statement?: InTotoStatement; + /** Signer information. */ + readonly signer?: SignerInfo; + /** Rekor entry (if verified). */ + readonly rekor_entry?: RekorLogEntry; + /** Error message (if failed). */ + readonly error?: string; +} + +// ============================================================================ +// Predicate Types +// ============================================================================ + +/** + * Well-known predicate type URIs. + */ +export const PredicateTypes = { + /** SPDX SBOM. */ + Spdx: 'https://spdx.dev/Document', + /** CycloneDX SBOM. */ + CycloneDx: 'https://cyclonedx.org/bom', + /** SLSA Provenance v1. */ + SlsaProvenance: 'https://slsa.dev/provenance/v1', + /** StellaOps Vulnerability Scan. */ + VulnScan: 'https://stellaops.io/attestation/vuln-scan/v1', + /** StellaOps Reachability Witness. */ + Witness: 'https://stellaops.io/attestation/witness/v1', + /** StellaOps Policy Decision. */ + PolicyDecision: 'https://stellaops.io/attestation/policy-decision/v1', + /** OpenVEX. */ + OpenVex: 'https://openvex.dev/ns/v0.2.0', +} as const; + +export type PredicateType = typeof PredicateTypes[keyof typeof PredicateTypes]; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Decodes base64-encoded DSSE payload. + */ +export function decodeDssePayload(envelope: DsseEnvelope): T { + const decoded = atob(envelope.payload); + return JSON.parse(decoded) as T; +} + +/** + * Gets the digest from a subject by algorithm preference. + */ +export function getSubjectDigest( + subject: InTotoSubject, + preferredAlgorithm: string = 'sha256' +): string | undefined { + return subject.digest[preferredAlgorithm] ?? Object.values(subject.digest)[0]; +} + +/** + * Checks if a chain is fully verified. + */ +export function isChainVerified(chain: AttestationChain): boolean { + return chain.status === 'verified'; +} + +/** + * Gets human-readable status label. + */ +export function getChainStatusLabel(status: AttestationChainStatus): string { + switch (status) { + case 'verified': + return 'Verified'; + case 'signature_invalid': + return 'Invalid Signature'; + case 'chain_broken': + return 'Chain Broken'; + case 'expired': + return 'Expired'; + case 'untrusted_signer': + return 'Untrusted Signer'; + case 'pending': + return 'Pending Verification'; + default: + return 'Unknown'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/triage-evidence.client.ts b/src/Web/StellaOps.Web/src/app/core/api/triage-evidence.client.ts new file mode 100644 index 00000000..ac0cff71 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/triage-evidence.client.ts @@ -0,0 +1,351 @@ +/** + * Triage Evidence API Client + * Sprint: SPRINT_4100_0001_0001_triage_models + * Provides API client for fetching finding evidence from Scanner service. + */ + +import { Injectable, InjectionToken, inject, signal } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, of, map, shareReplay, catchError, throwError } from 'rxjs'; + +import { + FindingEvidenceResponse, + FindingEvidenceRequest, + FindingEvidenceListResponse, + ComponentRef, + ScoreExplanation, + VexEvidence, + BoundaryProof, + EntrypointProof, +} from './triage-evidence.models'; +import { TenantActivationService } from '../auth/tenant-activation.service'; +import { generateTraceId } from './trace.util'; + +/** + * Triage Evidence API interface. + */ +export interface TriageEvidenceApi { + /** Get evidence for a specific finding. */ + getFindingEvidence( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable; + + /** Get evidence by CVE ID. */ + getEvidenceByCve( + cve: string, + options?: TriageEvidenceQueryOptions + ): Observable; + + /** Get evidence by component PURL. */ + getEvidenceByComponent( + purl: string, + options?: TriageEvidenceQueryOptions + ): Observable; + + /** List all evidence with pagination. */ + list( + options?: TriageEvidenceQueryOptions & PaginationOptions + ): Observable; + + /** Get score explanation for a finding. */ + getScoreExplanation( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable; + + /** Get VEX evidence for a finding. */ + getVexEvidence( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable; +} + +export interface TriageEvidenceQueryOptions { + readonly tenantId?: string; + readonly projectId?: string; + readonly traceId?: string; + readonly include_path?: boolean; + readonly include_boundary?: boolean; + readonly include_vex?: boolean; + readonly include_score?: boolean; +} + +export interface PaginationOptions { + readonly page?: number; + readonly page_size?: number; +} + +export const TRIAGE_EVIDENCE_API = new InjectionToken('TRIAGE_EVIDENCE_API'); + +/** + * HTTP implementation of the Triage Evidence API. + */ +@Injectable() +export class TriageEvidenceHttpClient implements TriageEvidenceApi { + private readonly http = inject(HttpClient); + private readonly tenantService = inject(TenantActivationService, { optional: true }); + + private readonly baseUrl = signal('/api/v1/scanner'); + + // Cache for frequently accessed evidence + private readonly evidenceCache = new Map>(); + private readonly cacheMaxAge = 60_000; // 1 minute + + getFindingEvidence( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable { + const cacheKey = this.buildCacheKey('finding', findingId, options); + + if (this.evidenceCache.has(cacheKey)) { + return this.evidenceCache.get(cacheKey)!; + } + + const url = `${this.baseUrl()}/evidence/${encodeURIComponent(findingId)}`; + const params = this.buildParams(options); + const headers = this.buildHeaders(options); + + const request$ = this.http.get(url, { params, headers }).pipe( + shareReplay({ bufferSize: 1, refCount: true }), + catchError(this.handleError) + ); + + this.evidenceCache.set(cacheKey, request$); + setTimeout(() => this.evidenceCache.delete(cacheKey), this.cacheMaxAge); + + return request$; + } + + getEvidenceByCve( + cve: string, + options?: TriageEvidenceQueryOptions + ): Observable { + const url = `${this.baseUrl()}/evidence`; + const params = this.buildParams({ ...options, cve }); + const headers = this.buildHeaders(options); + + return this.http.get(url, { params, headers }).pipe( + catchError(this.handleError) + ); + } + + getEvidenceByComponent( + purl: string, + options?: TriageEvidenceQueryOptions + ): Observable { + const url = `${this.baseUrl()}/evidence`; + const params = this.buildParams({ ...options, component_purl: purl }); + const headers = this.buildHeaders(options); + + return this.http.get(url, { params, headers }).pipe( + catchError(this.handleError) + ); + } + + list( + options?: TriageEvidenceQueryOptions & PaginationOptions + ): Observable { + const url = `${this.baseUrl()}/evidence`; + const params = this.buildParams(options); + const headers = this.buildHeaders(options); + + return this.http.get(url, { params, headers }).pipe( + catchError(this.handleError) + ); + } + + getScoreExplanation( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable { + return this.getFindingEvidence(findingId, { ...options, include_score: true }).pipe( + map((evidence) => { + if (!evidence.score_explain) { + throw new Error(`No score explanation available for finding ${findingId}`); + } + return evidence.score_explain; + }) + ); + } + + getVexEvidence( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable { + return this.getFindingEvidence(findingId, { ...options, include_vex: true }).pipe( + map((evidence) => evidence.vex ?? null) + ); + } + + /** + * Invalidate cached evidence for a finding. + */ + invalidateCache(findingId?: string): void { + if (findingId) { + // Remove all cache entries for this finding + for (const key of this.evidenceCache.keys()) { + if (key.includes(findingId)) { + this.evidenceCache.delete(key); + } + } + } else { + this.evidenceCache.clear(); + } + } + + private buildParams(options?: Record): HttpParams { + let params = new HttpParams(); + + if (options) { + for (const [key, value] of Object.entries(options)) { + if (value !== undefined && value !== null && key !== 'tenantId' && key !== 'traceId') { + params = params.set(key, String(value)); + } + } + } + + return params; + } + + private buildHeaders(options?: TriageEvidenceQueryOptions): Record { + const headers: Record = {}; + + const tenantId = options?.tenantId ?? this.tenantService?.activeTenantId(); + if (tenantId) { + headers['X-Tenant-Id'] = tenantId; + } + + const traceId = options?.traceId ?? generateTraceId(); + headers['X-Trace-Id'] = traceId; + + return headers; + } + + private buildCacheKey(type: string, id: string, options?: TriageEvidenceQueryOptions): string { + const opts = JSON.stringify(options ?? {}); + return `${type}:${id}:${opts}`; + } + + private handleError(error: unknown): Observable { + console.error('[TriageEvidenceClient] API error:', error); + return throwError(() => error); + } +} + +/** + * Mock implementation for testing and development. + */ +@Injectable() +export class TriageEvidenceMockClient implements TriageEvidenceApi { + private readonly mockEvidence: FindingEvidenceResponse = { + finding_id: 'finding-mock-001', + cve: 'CVE-2021-44228', + component: { + purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', + name: 'log4j-core', + version: '2.14.1', + type: 'maven', + }, + reachable_path: [ + 'com.example.App.main', + 'com.example.Service.process', + 'org.apache.logging.log4j.Logger.log', + ], + entrypoint: { + type: 'http_handler', + route: '/api/v1/process', + method: 'POST', + auth: 'required', + fqn: 'com.example.Controller.process', + }, + score_explain: { + kind: 'stellaops_risk_v1', + risk_score: 75.0, + contributions: [ + { + factor: 'cvss_base', + weight: 5.0, + raw_value: 10.0, + contribution: 50.0, + explanation: 'Critical CVSS base score', + source: 'nvd', + }, + { + factor: 'reachability', + weight: 1.0, + raw_value: 25.0, + contribution: 25.0, + explanation: 'Reachable from HTTP entrypoint', + source: 'scan', + }, + ], + last_seen: new Date().toISOString(), + algorithm_version: '1.0.0', + summary: 'High risk (75/100) driven by cvss_base and reachability', + }, + last_seen: new Date().toISOString(), + attestation_refs: ['dsse:sha256:mock123'], + }; + + getFindingEvidence( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable { + return of({ ...this.mockEvidence, finding_id: findingId }); + } + + getEvidenceByCve( + cve: string, + options?: TriageEvidenceQueryOptions + ): Observable { + return of({ + items: [{ ...this.mockEvidence, cve }], + total: 1, + page: 1, + page_size: 20, + }); + } + + getEvidenceByComponent( + purl: string, + options?: TriageEvidenceQueryOptions + ): Observable { + return of({ + items: [ + { + ...this.mockEvidence, + component: { ...this.mockEvidence.component!, purl }, + }, + ], + total: 1, + page: 1, + page_size: 20, + }); + } + + list( + options?: TriageEvidenceQueryOptions & PaginationOptions + ): Observable { + return of({ + items: [this.mockEvidence], + total: 1, + page: options?.page ?? 1, + page_size: options?.page_size ?? 20, + }); + } + + getScoreExplanation( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable { + return of(this.mockEvidence.score_explain!); + } + + getVexEvidence( + findingId: string, + options?: TriageEvidenceQueryOptions + ): Observable { + return of(null); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts b/src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts new file mode 100644 index 00000000..1a286ca2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts @@ -0,0 +1,265 @@ +/** + * Triage Evidence Models + * Sprint: SPRINT_4100_0001_0001_triage_models + * Mirrors backend contracts from Scanner.WebService/Contracts/FindingEvidenceContracts.cs + */ + +// ============================================================================ +// Core Evidence Response +// ============================================================================ + +/** + * Unified evidence response for a finding, combining reachability, boundary, + * VEX evidence, and score explanation. + */ +export interface FindingEvidenceResponse { + readonly finding_id: string; + readonly cve: string; + readonly component?: ComponentRef; + readonly reachable_path?: readonly string[]; + readonly entrypoint?: EntrypointProof; + readonly boundary?: BoundaryProof; + readonly vex?: VexEvidence; + readonly score_explain?: ScoreExplanation; + readonly last_seen: string; // ISO 8601 + readonly expires_at?: string; + readonly attestation_refs?: readonly string[]; +} + +/** + * Reference to a component (package) by PURL and version. + */ +export interface ComponentRef { + readonly purl: string; + readonly name: string; + readonly version: string; + readonly type: string; +} + +// ============================================================================ +// Entrypoint Proof +// ============================================================================ + +/** + * Proof of how code is exposed as an entrypoint. + */ +export interface EntrypointProof { + readonly type: string; // http_handler, grpc_method, cli_command, etc. + readonly route?: string; + readonly method?: string; + readonly auth?: string; // none, optional, required + readonly phase?: string; // startup, runtime, shutdown + readonly fqn: string; + readonly location?: SourceLocation; +} + +/** + * Source file location reference. + */ +export interface SourceLocation { + readonly file: string; + readonly line?: number; + readonly column?: number; +} + +// ============================================================================ +// Boundary Proof +// ============================================================================ + +/** + * Boundary proof describing surface exposure and controls. + */ +export interface BoundaryProof { + readonly kind: string; + readonly surface?: SurfaceDescriptor; + readonly exposure?: ExposureDescriptor; + readonly auth?: AuthDescriptor; + readonly controls?: readonly ControlDescriptor[]; + readonly last_seen: string; + readonly confidence: number; +} + +/** + * Describes what attack surface is exposed. + */ +export interface SurfaceDescriptor { + readonly type: string; + readonly protocol?: string; + readonly port?: number; +} + +/** + * Describes how the surface is exposed. + */ +export interface ExposureDescriptor { + readonly level: string; // public, internal, private + readonly internet_facing: boolean; + readonly zone?: string; +} + +/** + * Describes authentication requirements. + */ +export interface AuthDescriptor { + readonly required: boolean; + readonly type?: string; + readonly roles?: readonly string[]; +} + +/** + * Describes a security control. + */ +export interface ControlDescriptor { + readonly type: string; + readonly active: boolean; + readonly config?: string; +} + +// ============================================================================ +// VEX Evidence +// ============================================================================ + +/** + * VEX (Vulnerability Exploitability eXchange) evidence. + */ +export interface VexEvidence { + readonly status: VexStatus; + readonly justification?: string; + readonly impact?: string; + readonly action?: string; + readonly attestation_ref?: string; + readonly issued_at?: string; + readonly expires_at?: string; + readonly source?: string; +} + +/** + * VEX status values per OpenVEX specification. + */ +export type VexStatus = 'not_affected' | 'affected' | 'fixed' | 'under_investigation'; + +// ============================================================================ +// Score Explanation +// ============================================================================ + +/** + * Score explanation with additive breakdown of risk factors. + */ +export interface ScoreExplanation { + readonly kind: string; + readonly risk_score: number; + readonly contributions?: readonly ScoreContribution[]; + readonly last_seen: string; + readonly algorithm_version?: string; + readonly evidence_ref?: string; + readonly summary?: string; + readonly modifiers?: readonly ScoreModifier[]; +} + +/** + * Individual contribution to the risk score. + */ +export interface ScoreContribution { + readonly factor: string; + readonly weight: number; + readonly raw_value: number; + readonly contribution: number; + readonly explanation?: string; + readonly source?: string; + readonly updated_at?: string; + readonly confidence?: number; +} + +/** + * Modifier applied to the score after base calculation. + */ +export interface ScoreModifier { + readonly type: string; + readonly before: number; + readonly after: number; + readonly reason?: string; + readonly policy_ref?: string; +} + +/** + * Well-known score factor names. + */ +export const ScoreFactors = { + CvssBase: 'cvss_base', + CvssEnvironmental: 'cvss_environmental', + Epss: 'epss', + Reachability: 'reachability', + GateMultiplier: 'gate_multiplier', + VexOverride: 'vex_override', + TimeDecay: 'time_decay', + ExposureSurface: 'exposure_surface', + KnownExploitation: 'known_exploitation', + AssetCriticality: 'asset_criticality', +} as const; + +export type ScoreFactor = typeof ScoreFactors[keyof typeof ScoreFactors]; + +// ============================================================================ +// Query Interfaces +// ============================================================================ + +/** + * Request for finding evidence. + */ +export interface FindingEvidenceRequest { + readonly finding_id?: string; + readonly cve?: string; + readonly component_purl?: string; + readonly include_path?: boolean; + readonly include_boundary?: boolean; + readonly include_vex?: boolean; + readonly include_score?: boolean; +} + +/** + * List response for multiple findings. + */ +export interface FindingEvidenceListResponse { + readonly items: readonly FindingEvidenceResponse[]; + readonly total: number; + readonly page: number; + readonly page_size: number; +} + +// ============================================================================ +// Severity Helpers +// ============================================================================ + +/** + * Returns severity label based on score. + */ +export function getSeverityLabel(score: number): 'critical' | 'high' | 'medium' | 'low' | 'minimal' { + if (score >= 80) return 'critical'; + if (score >= 60) return 'high'; + if (score >= 40) return 'medium'; + if (score >= 20) return 'low'; + return 'minimal'; +} + +/** + * Returns CSS class for severity. + */ +export function getSeverityClass(score: number): string { + return `severity-${getSeverityLabel(score)}`; +} + +/** + * Checks if VEX status indicates non-exploitability. + */ +export function isVexNotAffected(vex?: VexEvidence): boolean { + return vex?.status === 'not_affected'; +} + +/** + * Checks if VEX evidence is still valid (not expired). + */ +export function isVexValid(vex?: VexEvidence): boolean { + if (!vex) return false; + if (!vex.expires_at) return true; + return new Date(vex.expires_at) > new Date(); +}