Add unit tests for PhpFrameworkSurface and PhpPharScanner
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
- Implement comprehensive tests for PhpFrameworkSurface, covering scenarios such as empty surfaces, presence of routes, controllers, middlewares, CLI commands, cron jobs, and event listeners. - Validate metadata creation for route counts, HTTP methods, protected and public routes, and route patterns. - Introduce tests for PhpPharScanner, including handling of non-existent files, null or empty paths, invalid PHAR files, and minimal PHAR structures. - Ensure correct computation of SHA256 for valid PHAR files and validate the properties of PhpPharArchive, PhpPharEntry, and PhpPharScanResult.
This commit is contained in:
@@ -4,9 +4,4 @@
|
||||
<add key="globalPackagesFolder" value="$(HOME)/.nuget/packages" />
|
||||
<add key="fallbackPackageFolders" value="" />
|
||||
</config>
|
||||
<packageSources>
|
||||
<add key="local-nugets" value="./local-nugets" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
<add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
|
||||
37
docs/api/console/samples/console-download-manifest.json
Normal file
37
docs/api/console/samples/console-download-manifest.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": "2025-12-07",
|
||||
"exportId": "console-export::tenant-default::2025-12-07::0009",
|
||||
"tenantId": "tenant-default",
|
||||
"generatedAt": "2025-12-07T10:15:00Z",
|
||||
"items": [
|
||||
{
|
||||
"type": "vuln",
|
||||
"id": "CVE-2024-12345",
|
||||
"format": "json",
|
||||
"url": "https://downloads.local/exports/0009/vuln/CVE-2024-12345.json?sig=abc",
|
||||
"sha256": "f1c5a94d5e7e0b12f8a6c3b9e2f3d1017c6b9c1c822f4d2d5fa0c3e46f0e9a10",
|
||||
"size": 18432
|
||||
},
|
||||
{
|
||||
"type": "vex",
|
||||
"id": "vex:tenant-default:jwt-auth:5d1a",
|
||||
"format": "ndjson",
|
||||
"url": "https://downloads.local/exports/0009/vex/vex-tenant-default-jwt-auth-5d1a.ndjson?sig=def",
|
||||
"sha256": "3a2d0edc2bfa4c5c9e1a7f96b0b5e6de378c1f9baf2d6f2a7e9c5d4b3f0c1a2e",
|
||||
"size": 9216
|
||||
},
|
||||
{
|
||||
"type": "bundle",
|
||||
"id": "console-export::tenant-default::2025-12-07::0009",
|
||||
"format": "tar.gz",
|
||||
"url": "https://downloads.local/exports/0009/bundle.tar.gz?sig=ghi",
|
||||
"sha256": "12ae34f51c2b4c6d7e8f90ab1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7081920a",
|
||||
"size": 48732102
|
||||
}
|
||||
],
|
||||
"checksums": {
|
||||
"manifest": "sha256:8bbf3cc1f8c7d6e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0dffeeddccbbaa99887",
|
||||
"bundle": "sha256:12ae34f51c2b4c6d7e8f90ab1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7081920a"
|
||||
},
|
||||
"expiresAt": "2025-12-14T10:15:00Z"
|
||||
}
|
||||
58
docs/api/console/search-downloads.md
Normal file
58
docs/api/console/search-downloads.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Console Search & Downloads · Draft v0.2
|
||||
|
||||
Scope: unblock WEB-CONSOLE-23-004/005 by defining deterministic ranking, caching rules, and the download manifest structure (including signed metadata option) for console search and offline bundle downloads. Final guild sign-off still required.
|
||||
|
||||
## 1) Deterministic search ranking
|
||||
- Primary sort: `severity (desc)` → `exploitScore (desc)` → `reachability (reachable > unknown > unreachable)` → `policyBadge (fail > warn > pass > waived)` → `vexState (under_investigation > fixed > not_affected > unknown)` → `findingId (asc)`.
|
||||
- Secondary tie-breakers (when above fields absent): `advisoryId (asc)` then `product (asc)`.
|
||||
- All pages are pre-sorted server-side; clients MUST NOT re-order.
|
||||
|
||||
## 2) Caching + freshness
|
||||
- Response headers: `Cache-Control: public, max-age=300, stale-while-revalidate=60, stale-if-error=300`.
|
||||
- `ETag` is a stable SHA-256 over the sorted payload; clients send `If-None-Match` for revalidation.
|
||||
- `Last-Modified` reflects the newest `updatedAt` in the result set.
|
||||
- Retry/backoff guidance: honor `Retry-After` when present; default client backoff `1s,2s,4s,8s` capped at 30s.
|
||||
- Deterministic page cursors: opaque base64url, signed; include `sortKeys` and `tenant` to avoid cross-tenant reuse.
|
||||
|
||||
## 3) Download manifest (for `/console/downloads` and export outputs)
|
||||
Top-level:
|
||||
```jsonc
|
||||
{
|
||||
"version": "2025-12-07",
|
||||
"exportId": "console-export::tenant-default::2025-12-07::0009",
|
||||
"tenantId": "tenant-default",
|
||||
"generatedAt": "2025-12-07T10:15:00Z",
|
||||
"items": [
|
||||
{
|
||||
"type": "vuln", // advisory|vex|policy|scan|chart|bundle
|
||||
"id": "CVE-2024-12345",
|
||||
"format": "json",
|
||||
"url": "https://downloads.local/exports/0009/vuln/CVE-2024-12345.json?sig=...",
|
||||
"sha256": "f1c5…",
|
||||
"size": 18432
|
||||
}
|
||||
],
|
||||
"checksums": {
|
||||
"manifest": "sha256:8bbf…",
|
||||
"bundle": "sha256:12ae…" // optional when a tar/zip bundle is produced
|
||||
},
|
||||
"expiresAt": "2025-12-14T10:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 Signed metadata
|
||||
- Optional DSSE envelope for `checksums.manifest`, using `sha256` digest and `application/json` payload type `stellaops.console.manifest`.
|
||||
- Envelope is attached as `manifest.dsse` or provided via `Link: <...>; rel="alternate"; type="application/dsse+json"`.
|
||||
- Signers: Authority-issued short-lived key scoped to `console:export`.
|
||||
|
||||
### 3.2 Error handling
|
||||
- Known error codes: `ERR_CONSOLE_DOWNLOAD_INVALID_CURSOR`, `ERR_CONSOLE_DOWNLOAD_EXPIRED`, `ERR_CONSOLE_DOWNLOAD_RATE_LIMIT`, `ERR_CONSOLE_DOWNLOAD_UNAVAILABLE`.
|
||||
- On error, respond with deterministic JSON body including `requestId` and `retryAfterSeconds` when applicable.
|
||||
|
||||
## 4) Sample manifest
|
||||
- `docs/api/console/samples/console-download-manifest.json` illustrates the exact shape above.
|
||||
|
||||
## 5) Open items for guild sign-off
|
||||
- Final TTL values for `max-age` and `stale-*`.
|
||||
- Whether DSSE envelope is mandatory for sealed tenants.
|
||||
- Maximum bundle size / item count caps (proposal: 1000 items, 500 MiB compressed per export).
|
||||
@@ -32,7 +32,7 @@
|
||||
- **Wave A (observability + replay):** Tasks 0–2 DONE; metrics and harness frozen; keep schemas stable for downstream Ops/DevOps sprints.
|
||||
- **Wave B (provenance exports):** Task 4 DONE; uses orchestrator export contract (now marked DONE). Keep linkage stable.
|
||||
- **Wave C (air-gap provenance — COMPLETE):** Tasks 5–8 ALL DONE (2025-12-06). Staleness validation, evidence snapshots, and timeline impact events implemented.
|
||||
- **Wave D (attestation pointers):** Task 9 TODO; unblocked by `docs/schemas/attestation-pointer.schema.json`.
|
||||
- **Wave D (attestation pointers — COMPLETE):** Task 9 DONE (2025-12-07). Full attestation pointer infrastructure implemented.
|
||||
- **Wave E (deployment collateral):** Task 3 BLOCKED pending DevOps paths for manifests/offline kit. Run after Wave C to avoid conflicting asset locations.
|
||||
- Do not start blocked waves until dependencies land; avoid drift by keeping current DONE artifacts immutable.
|
||||
|
||||
@@ -61,11 +61,12 @@
|
||||
| 6 | LEDGER-AIRGAP-56-002 | **DONE** (2025-12-06) | Implemented AirGapOptions, StalenessValidationService, staleness metrics. | Findings Ledger Guild, AirGap Time Guild / `src/Findings/StellaOps.Findings.Ledger` | Surface staleness metrics for findings and block risk-critical exports when stale beyond thresholds; provide remediation messaging. |
|
||||
| 7 | LEDGER-AIRGAP-57-001 | **DONE** (2025-12-06) | Implemented EvidenceSnapshotService with cross-enclave verification. | Findings Ledger Guild, Evidence Locker Guild / `src/Findings/StellaOps.Findings.Ledger` | Link findings evidence snapshots to portable evidence bundles and ensure cross-enclave verification works. |
|
||||
| 8 | LEDGER-AIRGAP-58-001 | **DONE** (2025-12-06) | Implemented AirgapTimelineService with timeline impact events. | Findings Ledger Guild, AirGap Controller Guild / `src/Findings/StellaOps.Findings.Ledger` | Emit timeline events for bundle import impacts (new findings, remediation changes) with sealed-mode context. |
|
||||
| 9 | LEDGER-ATTEST-73-001 | TODO | Unblocked: Attestation pointer schema at `docs/schemas/attestation-pointer.schema.json` | Findings Ledger Guild, Attestor Service Guild / `src/Findings/StellaOps.Findings.Ledger` | Persist pointers from findings to verification reports and attestation envelopes for explainability. |
|
||||
| 9 | LEDGER-ATTEST-73-001 | **DONE** (2025-12-07) | Implemented AttestationPointerService, PostgresAttestationPointerRepository, WebService endpoints, migration. | Findings Ledger Guild, Attestor Service Guild / `src/Findings/StellaOps.Findings.Ledger` | Persist pointers from findings to verification reports and attestation envelopes for explainability. |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-12-07 | **LEDGER-ATTEST-73-001 DONE:** Implemented AttestationPointerRecord, IAttestationPointerRepository, PostgresAttestationPointerRepository, AttestationPointerService, WebService endpoints (POST/GET/PUT /v1/ledger/attestation-pointers), migration 008_attestation_pointers.sql, and unit tests. Added attestation.pointer_linked ledger event type and timeline logging. Wave D complete. | Implementer |
|
||||
| 2025-12-06 | **LEDGER-ATTEST-73-001 Unblocked:** Changed from BLOCKED to TODO. Attestation pointer schema now available at `docs/schemas/attestation-pointer.schema.json`. Wave D can proceed. | Implementer |
|
||||
| 2025-12-06 | **LEDGER-AIRGAP-56-002 DONE:** Implemented AirGapOptions (staleness config), StalenessValidationService (export blocking with ERR_AIRGAP_STALE), extended IAirgapImportRepository with staleness queries, added ledger_airgap_staleness_seconds and ledger_staleness_validation_failures_total metrics. | Implementer |
|
||||
| 2025-12-06 | **LEDGER-AIRGAP-57-001 DONE:** Implemented EvidenceSnapshotRecord, IEvidenceSnapshotRepository, EvidenceSnapshotService with cross-enclave verification. Added airgap.evidence_snapshot_linked ledger event type and timeline logging. | Implementer |
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | EXCITITOR-CONSOLE-23-001/002/003 | DONE (2025-11-23) | Dependent APIs live | Excititor Guild · Docs Guild | Console VEX endpoints (grouped statements, counts, search) with provenance + RBAC; metrics for policy explain. |
|
||||
| 2 | EXCITITOR-CONN-SUSE-01-003 | TODO | Upstream EXCITITOR-CONN-SUSE-01-002; ATLN schema | Connector Guild (SUSE) | Emit trust config (signer fingerprints, trust tier) in provenance; aggregation-only. |
|
||||
| 3 | EXCITITOR-CONN-UBUNTU-01-003 | TODO | EXCITITOR-CONN-UBUNTU-01-002; ATLN schema | Connector Guild (Ubuntu) | Emit Ubuntu signing metadata in provenance; aggregation-only. |
|
||||
| 2 | EXCITITOR-CONN-SUSE-01-003 | **DONE** (2025-12-07) | Integrated ConnectorSignerMetadataEnricher in provenance | Connector Guild (SUSE) | Emit trust config (signer fingerprints, trust tier) in provenance; aggregation-only. |
|
||||
| 3 | EXCITITOR-CONN-UBUNTU-01-003 | **DONE** (2025-12-07) | Verified enricher integration, fixed Logger reference | Connector Guild (Ubuntu) | Emit Ubuntu signing metadata in provenance; aggregation-only. |
|
||||
| 4 | EXCITITOR-CORE-AOC-19-002/003/004/013 | TODO | ATLN schema freeze | Excititor Core Guild | Deterministic advisory/PURL extraction, append-only linksets, remove consensus logic, seed Authority tenants in tests. |
|
||||
| 5 | EXCITITOR-GRAPH-21-001..005 | TODO/BLOCKED | Link-Not-Merge schema + overlay contract | Excititor Core · Storage Mongo · UI Guild | Batched VEX fetches, overlay metadata, indexes/materialized views for graph inspector. |
|
||||
| 6 | EXCITITOR-OBS-52/53/54 | TODO/BLOCKED | Evidence Locker DSSE + provenance schema | Excititor Core · Evidence Locker · Provenance Guilds | Timeline events + Merkle locker payloads + DSSE attestations for evidence batches. |
|
||||
@@ -53,6 +53,7 @@
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-12-07 | **EXCITITOR-CONN-SUSE-01-003 & EXCITITOR-CONN-UBUNTU-01-003 DONE:** Integrated `ConnectorSignerMetadataEnricher.Enrich()` into both connectors' `AddProvenanceMetadata()` methods. This adds external signer metadata (fingerprints, issuer tier, bundle info) from `STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH` environment variable to VEX document provenance. Fixed Ubuntu connector's `_logger` → `Logger` reference bug. | Implementer |
|
||||
| 2025-12-05 | Reconstituted sprint from `tasks-all.md`; prior redirect pointed to non-existent canonical. Added template and delivery tracker; tasks set per backlog. | Project Mgmt |
|
||||
| 2025-11-23 | Console VEX endpoints (tasks 1) delivered. | Excititor Guild |
|
||||
|
||||
|
||||
@@ -26,16 +26,18 @@
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | LEDGER-ATTEST-73-002 | BLOCKED | Waiting on LEDGER-ATTEST-73-001 verification pipeline delivery | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Enable search/filter in findings projections by verification result and attestation status |
|
||||
| 2 | LEDGER-OAS-61-001-DEV | TODO | Unblocked: OAS baseline available at `docs/schemas/findings-ledger-api.openapi.yaml` | Findings Ledger Guild; API Contracts Guild / `src/Findings/StellaOps.Findings.Ledger` | Expand Findings Ledger OAS to include projections, evidence lookups, and filter parameters with examples |
|
||||
| 2 | LEDGER-OAS-61-001-DEV | **DONE** (2025-12-07) | Expanded OAS with attestation pointer endpoints, schemas, and examples | Findings Ledger Guild; API Contracts Guild / `src/Findings/StellaOps.Findings.Ledger` | Expand Findings Ledger OAS to include projections, evidence lookups, and filter parameters with examples |
|
||||
| 3 | LEDGER-OAS-61-002-DEV | BLOCKED | PREP-LEDGER-OAS-61-002-DEPENDS-ON-61-001-CONT | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Implement `/.well-known/openapi` endpoint and ensure version metadata matches release |
|
||||
| 4 | LEDGER-OAS-62-001-DEV | BLOCKED | PREP-LEDGER-OAS-62-001-SDK-GENERATION-PENDING | Findings Ledger Guild; SDK Generator Guild / `src/Findings/StellaOps.Findings.Ledger` | Provide SDK test cases for findings pagination, filtering, evidence links; ensure typed models expose provenance |
|
||||
| 5 | LEDGER-OAS-63-001-DEV | BLOCKED | PREP-LEDGER-OAS-63-001-DEPENDENT-ON-SDK-VALID | Findings Ledger Guild; API Governance Guild / `src/Findings/StellaOps.Findings.Ledger` | Support deprecation headers and Notifications for retiring finding endpoints |
|
||||
| 6 | LEDGER-OBS-55-001 | BLOCKED | PREP-LEDGER-OBS-55-001-DEPENDS-ON-54-001-ATTE | Findings Ledger Guild; DevOps Guild / `src/Findings/StellaOps.Findings.Ledger` | Enhance incident mode to record replay diagnostics (lag traces, conflict snapshots), extend retention while active, and emit activation events to timeline/notifier |
|
||||
| 7 | LEDGER-PACKS-42-001-DEV | TODO | Unblocked: Time-travel API available at `docs/schemas/ledger-time-travel-api.openapi.yaml` | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Provide snapshot/time-travel APIs and digestible exports for task pack simulation and CLI offline mode |
|
||||
| 7 | LEDGER-PACKS-42-001-DEV | **DONE** (2025-12-07) | Implemented snapshot/time-travel APIs with full endpoint coverage | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Provide snapshot/time-travel APIs and digestible exports for task pack simulation and CLI offline mode |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-12-07 | **LEDGER-PACKS-42-001-DEV DONE:** Implemented full snapshot/time-travel API infrastructure: (1) Domain models in SnapshotModels.cs (LedgerSnapshot, QueryPoint, TimeQueryFilters, ReplayRequest, DiffRequest, ChangeLogEntry, StalenessResult, etc.); (2) Repository interfaces ISnapshotRepository and ITimeTravelRepository; (3) PostgreSQL implementations PostgresSnapshotRepository and PostgresTimeTravelRepository; (4) SnapshotService orchestrating all time-travel operations; (5) WebService contracts in SnapshotContracts.cs; (6) 13 new API endpoints (/v1/ledger/snapshots CRUD, /v1/ledger/time-travel/{findings,vex,advisories}, /v1/ledger/replay, /v1/ledger/diff, /v1/ledger/changelog, /v1/ledger/staleness, /v1/ledger/current-point); (7) Database migration 009_snapshots.sql; (8) Unit tests in SnapshotServiceTests.cs with in-memory repository mocks. | Implementer |
|
||||
| 2025-12-07 | **LEDGER-OAS-61-001-DEV DONE:** Expanded `docs/schemas/findings-ledger-api.openapi.yaml` with attestation pointer endpoints (/attestation-pointers, /findings/{findingId}/attestation-pointers, /findings/{findingId}/attestation-summary), comprehensive schemas (AttestationPointer, AttestationRefDetail, SignerInfo, RekorEntryRef, VerificationResult, VerificationCheck, AttestationSummary), and request/response examples for search, create, and update operations. | Implementer |
|
||||
| 2025-12-06 | **Wave A/C Partial Unblock:** LEDGER-OAS-61-001-DEV and LEDGER-PACKS-42-001-DEV changed from BLOCKED to TODO. Root blockers resolved: OAS baseline at `docs/schemas/findings-ledger-api.openapi.yaml`, time-travel API at `docs/schemas/ledger-time-travel-api.openapi.yaml`. | Implementer |
|
||||
| 2025-12-03 | Added Wave Coordination outlining contract/incident/pack waves; statuses unchanged (all remain BLOCKED). | Project Mgmt |
|
||||
| 2025-11-25 | Carried forward all BLOCKED Findings Ledger items from Sprint 0121-0001-0001; no status changes until upstream contracts land. | Project Mgmt |
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Sprint 0146 · Scanner Analyzer Gap Closure
|
||||
|
||||
## Topic & Scope
|
||||
- Close Amber/Red items in scanner analyzer readiness (Java/.NET validation, PHP pipeline, Node Phase22 CI, runtime parity).
|
||||
- Decide on bun.lockb stance and reconcile Deno status discrepancies; publish Dart/Swift scope notes.
|
||||
- Produce CI evidence (TRX/binlogs), fixtures, and doc updates to mark readiness green.
|
||||
- **Working directory:** `src/Scanner`.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Requires dedicated clean CI runner for Java/.NET/Node Phase22 validation.
|
||||
- Coordinate with Concelier/Signals guilds for PHP autoload graph and runtime evidence mapping.
|
||||
- Safe to run in parallel with non-scanner sprints; uses isolated runners and docs under `docs/modules/scanner`.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/modules/scanner/readiness-checkpoints.md`
|
||||
- `docs/modules/scanner/php-analyzer-owner-manifest.md`
|
||||
- `docs/modules/scanner/bun-analyzer-gotchas.md`
|
||||
- `docs/reachability/DELIVERY_GUIDE.md` (runtime parity sections)
|
||||
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | SCAN-JAVA-VAL-0146-01 | TODO | Allocate clean runner; rerun Java analyzer suite and attach TRX/binlogs; update readiness to Green if passing. | Scanner · CI | Validate Java analyzer chain (21-005..011) on clean runner and publish evidence. |
|
||||
| 2 | SCAN-DOTNET-DESIGN-0146-02 | TODO | Finalize analyzer design 11-001; create fixtures/tests; CI run. | Scanner · CI | Unblock .NET analyzer chain (11-001..005) with design doc, fixtures, and passing CI evidence. |
|
||||
| 3 | SCAN-PHP-DESIGN-0146-03 | TODO | Composer/autoload spec + restore stability; new fixtures. | Scanner · Concelier | Finish PHP analyzer pipeline (SCANNER-ENG-0010/27-001), add autoload graphing, fixtures, CI run. |
|
||||
| 4 | SCAN-NODE-PH22-CI-0146-04 | TODO | Clean runner with trimmed graph; run `scripts/run-node-phase22-smoke.sh`; capture logs. | Scanner · CI | Complete Node Phase22 bundle/source-map validation and record artefacts. |
|
||||
| 5 | SCAN-DENO-STATUS-0146-05 | TODO | Reconcile readiness vs TASKS.md; add validation evidence if shipped. | Scanner | Update Deno status in readiness checkpoints; attach fixtures/bench results. |
|
||||
| 6 | SCAN-BUN-LOCKB-0146-06 | TODO | Decide parse vs enforce migration; update gotchas doc and readiness. | Scanner | Define bun.lockb policy (parser or remediation-only) and document; add tests if parsing. |
|
||||
| 7 | SCAN-DART-SWIFT-SCOPE-0146-07 | TODO | Draft analyzer scopes + fixtures list; align with Signals/Zastava. | Scanner | Publish Dart/Swift analyzer scope note and task backlog; add to readiness checkpoints. |
|
||||
| 8 | SCAN-RUNTIME-PARITY-0146-08 | TODO | Identify runtime hook gaps for Java/.NET/PHP; create implementation plan. | Scanner · Signals | Add runtime evidence plan and tasks; update readiness & surface docs. |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-12-07 | Sprint created to consolidate scanner analyzer gap closure tasks. | Planning |
|
||||
|
||||
## Decisions & Risks
|
||||
- CI runner availability may delay Java/.NET/Node validation; mitigate by reserving dedicated runner slice.
|
||||
- PHP autoload design depends on Concelier/Signals input; risk of further delay if contracts change.
|
||||
- bun.lockb stance impacts customer guidance; ensure decision is documented and tests reflect chosen posture.
|
||||
- Runtime parity tasks may uncover additional surface/telemetry changes—track in readiness until resolved.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2025-12-10: CI runner allocation decision.
|
||||
- 2025-12-14: Status review on Java/.NET/Node validations and PHP design.
|
||||
- 2025-12-21: Final readiness update and doc sync across scanner module.
|
||||
@@ -64,7 +64,7 @@
|
||||
| # | Action | Owner | Due | Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Publish console export bundle orchestration contract + manifest schema and streaming limits; add samples to `docs/api/console/samples/`. | Policy Guild · Console Guild | 2025-12-08 | DOING (draft published, awaiting guild sign-off) |
|
||||
| 2 | Define caching/tie-break rules and download manifest format (signed metadata) for `/console/search` + `/console/downloads`. | Policy Guild · DevOps Guild | 2025-12-09 | TODO |
|
||||
| 2 | Define caching/tie-break rules and download manifest format (signed metadata) for `/console/search` + `/console/downloads`. | Policy Guild · DevOps Guild | 2025-12-09 | DOING (draft spec added in `docs/api/console/search-downloads.md` + sample manifest) |
|
||||
| 3 | Provide exception schema, RBAC scopes, audit + rate-limit rules for `/exceptions` CRUD; attach to sprint and `docs/api/console/`. | Policy Guild · Platform Events | 2025-12-09 | TODO |
|
||||
| 4 | Restore PTY/shell capacity on web host (openpty exhaustion) to allow tests/builds. | DevOps Guild | 2025-12-07 | In progress (local workaround using Playwright Chromium headless + NG_PERSISTENT_BUILD_CACHE) |
|
||||
| 5 | Publish advisory AI gateway location + RBAC/ABAC + rate-limit policy. | BE-Base Platform | 2025-12-08 | TODO |
|
||||
@@ -87,6 +87,7 @@
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-12-07 | Drafted caching/tie-break rules and download manifest spec for `/console/search` and `/console/downloads`; added `docs/api/console/search-downloads.md` and sample `docs/api/console/samples/console-download-manifest.json`. Awaiting Policy/DevOps sign-off; keeps WEB-CONSOLE-23-004/005 formally BLOCKED until approved. | Project Mgmt |
|
||||
| 2025-12-07 | WEB-CONSOLE-23-003: console export client, store, and service specs now runnable locally using Playwright Chromium headless and `NG_PERSISTENT_BUILD_CACHE=1`; command: `CHROME_BIN=$HOME/.cache/ms-playwright/chromium-1140/chrome-linux/chrome NG_PERSISTENT_BUILD_CACHE=1 npm test -- --watch=false --browsers=ChromeHeadlessOffline --progress=false --include src/app/core/api/console-export.client.spec.ts,src/app/core/console/console-export.store.spec.ts,src/app/core/console/console-export.service.spec.ts`. Tests pass; backend contract still draft. | Implementer |
|
||||
| 2025-12-04 | WEB-CONSOLE-23-002 completed: wired `console/status` route in `app.routes.ts`; created sample payloads `console-status-sample.json` and `console-run-stream-sample.ndjson` in `docs/api/console/samples/` verified against `ConsoleStatusDto` and `ConsoleRunEventDto` contracts. | BE-Base Platform Guild |
|
||||
| 2025-12-02 | WEB-CONSOLE-23-002: added trace IDs on status/stream calls, heartbeat + exponential backoff reconnect in console run stream service, and new client/service unit tests. Backend commands still not run locally (disk constraint). | BE-Base Platform Guild |
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- docs/implplan/SPRINT_0514_0001_0001_sovereign_crypto_enablement.md
|
||||
- docs/contracts/crypto-provider-registry.md
|
||||
- docs/contracts/authority-crypto-provider.md
|
||||
- docs/legal/crypto-compliance-review.md (unblocks RU-CRYPTO-VAL-05/06)
|
||||
- docs/security/wine-csp-loader-design.md (technical design for Wine approach)
|
||||
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
@@ -31,11 +33,15 @@
|
||||
| --- | --- | --- |
|
||||
| 2025-12-06 | Sprint created; awaiting staffing. | Planning |
|
||||
| 2025-12-06 | Re-scoped: proceed with Linux OpenSSL GOST baseline (tasks 1–3 set to TODO); CSP/Wine/Legal remain BLOCKED (tasks 4–7). | Implementer |
|
||||
| 2025-12-07 | Published `docs/legal/crypto-compliance-review.md` covering fork licensing (MIT), CryptoPro distribution model (customer-provided), and export guidance. Provides partial unblock for RU-CRYPTO-VAL-05/06 pending legal sign-off. | Security |
|
||||
| 2025-12-07 | Published `docs/security/wine-csp-loader-design.md` with three architectural approaches for Wine CSP integration: (A) Full Wine environment, (B) Winelib bridge, (C) Wine RPC server (recommended). Includes validation scripts and CI integration plan. | Security |
|
||||
|
||||
## Decisions & Risks
|
||||
- Windows CSP availability may slip; mitigation: document manual runner setup and allow deferred close on #1/#6 (currently blocking).
|
||||
- Licensing/export could block redistribution; must finalize before RootPack publish (currently blocking task 3).
|
||||
- Cross-platform determinism must be proven; if mismatch, block release until fixed; currently waiting on #1/#2 data.
|
||||
- **Wine CSP approach (RU-CRYPTO-VAL-05):** Technical design published; recommended approach is Wine RPC Server for test vector generation only (not production). Requires legal review of CryptoPro EULA before implementation. See `docs/security/wine-csp-loader-design.md`.
|
||||
- **Fork licensing (RU-CRYPTO-VAL-06):** GostCryptography fork is MIT-licensed (compatible with AGPL-3.0). CryptoPro CSP is customer-provided. Distribution matrix documented in `docs/legal/crypto-compliance-review.md`. Awaiting legal sign-off.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2025-12-10 · Runner availability go/no-go.
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | SM-CRYPTO-01 | DONE (2025-12-06) | None | Security · Crypto | Implement `StellaOps.Cryptography.Plugin.SmSoft` provider using BouncyCastle SM2/SM3 (software-only, non-certified); env guard `SM_SOFT_ALLOWED` added. |
|
||||
| 2 | SM-CRYPTO-02 | DONE (2025-12-06) | After #1 | Security · BE (Authority/Signer) | Wire SM soft provider into DI (registered), compliance docs updated with “software-only” caveat. |
|
||||
| 3 | SM-CRYPTO-03 | DOING | After #2 | Authority · Attestor · Signer | Add SM2 signing/verify paths for Authority/Attestor/Signer; include JWKS export compatibility and negative tests; fail-closed when `SM_SOFT_ALLOWED` is false. Authority SM2 loader + JWKS tests done; Signer SM2 gate/tests added. Attestor wiring still pending. |
|
||||
| 3 | SM-CRYPTO-03 | DONE (2025-12-07) | After #2 | Authority · Attestor · Signer | Add SM2 signing/verify paths for Authority/Attestor/Signer; include JWKS export compatibility and negative tests; fail-closed when `SM_SOFT_ALLOWED` is false. Authority SM2 loader + JWKS tests done; Signer SM2 gate/tests added; Attestor SM2 wiring complete (SmSoftCryptoProvider registered, key loading, signing tests). |
|
||||
| 4 | SM-CRYPTO-04 | DONE (2025-12-06) | After #1 | QA · Security | Deterministic software test vectors (sign/verify, hash) added in unit tests; “non-certified” banner documented. |
|
||||
| 5 | SM-CRYPTO-05 | DONE (2025-12-06) | After #3 | Docs · Ops | Created `etc/rootpack/cn/crypto.profile.yaml` with cn-soft profile preferring `cn.sm.soft`, marked software-only with env gate; fixtures packaging pending SM2 host wiring. |
|
||||
| 6 | SM-CRYPTO-06 | BLOCKED (2025-12-06) | Hardware token available | Security · Crypto | Add PKCS#11 SM provider and rerun vectors with certified hardware; replace “software-only” label when certified. |
|
||||
@@ -34,6 +34,7 @@
|
||||
| 2025-12-06 | Added cn rootpack profile (software-only, env-gated); set task 5 to DONE; task 3 remains TODO pending host wiring. | Implementer |
|
||||
| 2025-12-06 | Started host wiring for SM2: Authority file key loader now supports SM2 raw keys; JWKS tests include SM2; task 3 set to DOING. | Implementer |
|
||||
| 2025-12-06 | Signer SM2 gate + tests added (software registry); Attestor wiring pending. Sm2 tests blocked by existing package restore issues (NU1608/fallback paths). | Implementer |
|
||||
| 2025-12-07 | Attestor SM2 wiring complete: SmSoftCryptoProvider registered in AttestorSigningKeyRegistry, SM2 key loading (PEM/base64/hex), signing tests added. Fixed AWSSDK version conflict and pre-existing test compilation issues. Task 3 set to DONE. | Implementer |
|
||||
|
||||
## Decisions & Risks
|
||||
- SM provider licensing/availability uncertain; mitigation: software fallback with “non-certified” label until hardware validated.
|
||||
|
||||
257
docs/legal/crypto-compliance-review.md
Normal file
257
docs/legal/crypto-compliance-review.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Crypto Compliance Review · License & Export Analysis
|
||||
|
||||
**Status:** DRAFT
|
||||
**Date:** 2025-12-07
|
||||
**Owners:** Security Guild, Legal
|
||||
**Unblocks:** RU-CRYPTO-VAL-05, RU-CRYPTO-VAL-06
|
||||
|
||||
## Overview
|
||||
|
||||
This document captures the licensing, export controls, and distribution guidance for cryptographic components in StellaOps, specifically:
|
||||
|
||||
1. **GostCryptography Fork** (`third_party/forks/AlexMAS.GostCryptography`)
|
||||
2. **CryptoPro Plugin** (`StellaOps.Cryptography.Plugin.CryptoPro`)
|
||||
3. **Regional Crypto Providers** (GOST, SM2/SM3, eIDAS)
|
||||
|
||||
## 1. GostCryptography Fork
|
||||
|
||||
### 1.1 License
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Upstream | https://github.com/AlexMAS/GostCryptography |
|
||||
| License | MIT |
|
||||
| StellaOps Usage | Source-vendored in `third_party/forks/` |
|
||||
| Compatibility | MIT is compatible with AGPL-3.0-or-later |
|
||||
|
||||
### 1.2 Attribution Requirements
|
||||
|
||||
The MIT license requires attribution in distributed software:
|
||||
|
||||
```
|
||||
Copyright (c) 2014-2024 AlexMAS
|
||||
See third_party/forks/AlexMAS.GostCryptography/LICENSE
|
||||
```
|
||||
|
||||
**Required Actions:**
|
||||
- [x] Keep `LICENSE` file in fork directory
|
||||
- [ ] Add attribution to `NOTICE.md` in repository root
|
||||
- [ ] Include attribution in RootPack_RU bundle documentation
|
||||
|
||||
### 1.3 Distribution Guidance
|
||||
|
||||
| Distribution Channel | Allowed | Notes |
|
||||
|---------------------|---------|-------|
|
||||
| StellaOps Source | Yes | Fork stays vendored |
|
||||
| RootPack_RU Bundle | Yes | Source + binaries allowed |
|
||||
| Public NuGet | **No** | Do not publish as standalone package |
|
||||
| Container Images | Yes | With source attribution |
|
||||
|
||||
## 2. CryptoPro CSP Plugin
|
||||
|
||||
### 2.1 License
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Vendor | CryptoPro LLC (crypto-pro.ru) |
|
||||
| Product | CryptoPro CSP 5.0 |
|
||||
| License Type | Commercial (per-deployment) |
|
||||
| Cost | Varies by tier (~$50-200 USD per instance) |
|
||||
|
||||
### 2.2 Distribution Model
|
||||
|
||||
CryptoPro CSP is **not redistributable** by StellaOps. The distribution model is:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Distribution Model │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ StellaOps ships: │
|
||||
│ ├── Plugin source code (AGPL-3.0-or-later) │
|
||||
│ ├── Interface bindings to CryptoPro CSP │
|
||||
│ └── Documentation for customer-provided CSP installation │
|
||||
│ │
|
||||
│ Customer provides: │
|
||||
│ ├── CryptoPro CSP license │
|
||||
│ ├── CSP binaries installed on target system │
|
||||
│ └── PKCS#11 module path configuration │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 Configuration for Customer-Provided CSP
|
||||
|
||||
```yaml
|
||||
# etc/authority.yaml - Customer configures CSP path
|
||||
crypto:
|
||||
pkcs11:
|
||||
library_path: /opt/cprocsp/lib/amd64/libcapi20.so # Customer-provided
|
||||
slot_id: 0
|
||||
pin_env: AUTHORITY_HSM_PIN
|
||||
```
|
||||
|
||||
### 2.4 Documentation Requirements
|
||||
|
||||
- [ ] Document that CSP is "customer-provided" in installation guide
|
||||
- [ ] Add EULA notice that CSP licensing is customer responsibility
|
||||
- [ ] Include CSP version compatibility matrix (CSP 4.0/5.0)
|
||||
|
||||
## 3. Export Control Analysis
|
||||
|
||||
### 3.1 Applicable Regulations
|
||||
|
||||
| Regulation | Jurisdiction | Relevance |
|
||||
|------------|--------------|-----------|
|
||||
| EAR (Export Administration Regulations) | USA | Crypto export controls |
|
||||
| Wassenaar Arrangement | 42 countries | Dual-use goods |
|
||||
| EU Dual-Use Regulation | EU | Crypto controls |
|
||||
| Russian Export Controls | Russia | GOST algorithm distribution |
|
||||
|
||||
### 3.2 Algorithm Classification
|
||||
|
||||
| Algorithm | Classification | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| ECDSA P-256/P-384 | Mass-market exempt | Widely available |
|
||||
| RSA 2048+ | Mass-market exempt | Widely available |
|
||||
| EdDSA (Ed25519) | Mass-market exempt | Widely available |
|
||||
| GOST R 34.10-2012 | Regional use | See Section 3.3 |
|
||||
| SM2/SM3 | Regional use | Chinese national standard |
|
||||
|
||||
### 3.3 GOST Algorithm Guidance
|
||||
|
||||
GOST algorithms (GOST R 34.10-2012, GOST R 34.11-2012) are:
|
||||
|
||||
- **Not export-controlled** from Russia when used in commercial software
|
||||
- **May be restricted** for import into certain jurisdictions
|
||||
- **Recommended** for use only in RootPack_RU deployments targeting Russian customers
|
||||
|
||||
**Guidance:**
|
||||
1. Default StellaOps distribution does NOT include GOST algorithms enabled
|
||||
2. RootPack_RU is a separate distribution with GOST opt-in
|
||||
3. Document that customers are responsible for compliance with local crypto regulations
|
||||
|
||||
### 3.4 Distribution Matrix
|
||||
|
||||
| Component | Global | RootPack_RU | RootPack_CN | Notes |
|
||||
|-----------|--------|-------------|-------------|-------|
|
||||
| Core StellaOps | Yes | Yes | Yes | ECDSA/RSA/EdDSA |
|
||||
| GostCryptography Fork | Source only | Source + Binary | No | MIT license |
|
||||
| CryptoPro Plugin | Interface only | Interface + docs | No | Customer-provided CSP |
|
||||
| SM2/SM3 Plugin | No | No | Interface + docs | Customer-provided HSM |
|
||||
|
||||
## 4. EULA and Notice Requirements
|
||||
|
||||
### 4.1 NOTICE.md Addition
|
||||
|
||||
Add to repository `NOTICE.md`:
|
||||
|
||||
```markdown
|
||||
## Third-Party Cryptographic Components
|
||||
|
||||
### GostCryptography (MIT License)
|
||||
Copyright (c) 2014-2024 AlexMAS
|
||||
https://github.com/AlexMAS/GostCryptography
|
||||
|
||||
This software includes a forked version of the GostCryptography library
|
||||
for GOST algorithm support. The fork is located at:
|
||||
third_party/forks/AlexMAS.GostCryptography/
|
||||
|
||||
### CryptoPro CSP Integration
|
||||
The CryptoPro CSP plugin provides integration with CryptoPro CSP software.
|
||||
CryptoPro CSP is commercial software and must be licensed separately by
|
||||
the end user. StellaOps does not distribute CryptoPro CSP binaries.
|
||||
```
|
||||
|
||||
### 4.2 Installation Guide Addition
|
||||
|
||||
Add to installation documentation:
|
||||
|
||||
```markdown
|
||||
## Regional Crypto Support (Optional)
|
||||
|
||||
### Russian Federation (RootPack_RU)
|
||||
|
||||
StellaOps supports GOST R 34.10-2012 signing through integration with
|
||||
CryptoPro CSP. This integration requires:
|
||||
|
||||
1. A valid CryptoPro CSP license (obtained separately from crypto-pro.ru)
|
||||
2. CryptoPro CSP 4.0 or 5.0 installed on the target system
|
||||
3. Configuration of the PKCS#11 module path
|
||||
|
||||
**Note:** CryptoPro CSP is commercial software. StellaOps provides only
|
||||
the integration plugin; the CSP runtime must be licensed and installed
|
||||
by the customer.
|
||||
```
|
||||
|
||||
## 5. CI/Testing Implications
|
||||
|
||||
### 5.1 Test Environment Requirements
|
||||
|
||||
| Environment | CSP Required | Legal Status |
|
||||
|-------------|--------------|--------------|
|
||||
| Development (Linux) | No | OpenSSL GOST engine fallback |
|
||||
| CI (Linux) | No | Mock/skip CSP tests |
|
||||
| CI (Windows opt-in) | Yes | Customer/StellaOps license |
|
||||
| Production | Customer | Customer license |
|
||||
|
||||
### 5.2 CI Guard Implementation
|
||||
|
||||
Tests are guarded by environment variable:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
[SkipUnless("STELLAOPS_CRYPTO_PRO_ENABLED", "1")]
|
||||
public async Task CryptoProSigner_SignsWithGost()
|
||||
{
|
||||
// Test only runs when CSP is available and licensed
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Wine Loader Experiment (RU-CRYPTO-VAL-05)
|
||||
|
||||
**Status:** BLOCKED pending legal review
|
||||
|
||||
Running CryptoPro CSP DLLs under Wine for cross-platform testing:
|
||||
|
||||
| Consideration | Assessment |
|
||||
|---------------|------------|
|
||||
| Technical Feasibility | Uncertain - CSP uses Windows APIs |
|
||||
| Legal Permissibility | Requires CryptoPro EULA review |
|
||||
| Recommendation | Defer to Windows-only testing |
|
||||
|
||||
**Decision:** Do not pursue Wine loader approach until/unless CryptoPro explicitly permits this use case in their EULA.
|
||||
|
||||
## 6. Action Items
|
||||
|
||||
### Immediate (unblocks RU-CRYPTO-VAL-06)
|
||||
|
||||
- [x] Document fork licensing (MIT) ← This document
|
||||
- [x] Document CryptoPro distribution model ← This document
|
||||
- [ ] Add attribution to NOTICE.md
|
||||
- [ ] Update installation guide with CSP requirements
|
||||
|
||||
### Short-term
|
||||
|
||||
- [ ] Review CryptoPro EULA for Wine usage (if needed)
|
||||
- [ ] Create regional distribution manifests for RootPack_RU
|
||||
- [ ] Add compliance checkboxes to RootPack_RU installation
|
||||
|
||||
### For Legal Sign-off
|
||||
|
||||
- [ ] Confirm MIT + AGPL-3.0 compatibility statement
|
||||
- [ ] Confirm customer-provided model for CSP is acceptable
|
||||
- [ ] Review export control applicability for GOST distribution
|
||||
|
||||
## 7. Sign-off Log
|
||||
|
||||
| Role | Name | Date | Notes |
|
||||
|------|------|------|-------|
|
||||
| Security Guild | | | |
|
||||
| Legal | | | |
|
||||
| Product | | | |
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0.0*
|
||||
*Last Updated: 2025-12-07*
|
||||
@@ -219,6 +219,240 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttestationListResponse'
|
||||
|
||||
/findings/{findingId}/attestation-pointers:
|
||||
get:
|
||||
operationId: getFindingAttestationPointers
|
||||
summary: Get attestation pointers linking finding to verification reports and attestation envelopes
|
||||
description: |
|
||||
Returns all attestation pointers for a finding. Attestation pointers link findings
|
||||
to verification reports, DSSE envelopes, SLSA provenance, VEX attestations, and other
|
||||
cryptographic evidence for explainability and audit trails.
|
||||
tags: [findings, attestation]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: List of attestation pointers
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttestationPointer'
|
||||
examples:
|
||||
verified_finding:
|
||||
summary: Finding with verified DSSE envelope
|
||||
value:
|
||||
- pointer_id: "a1b2c3d4-5678-90ab-cdef-123456789abc"
|
||||
finding_id: "f1234567-89ab-cdef-0123-456789abcdef"
|
||||
attestation_type: "DsseEnvelope"
|
||||
relationship: "VerifiedBy"
|
||||
attestation_ref:
|
||||
digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd"
|
||||
storage_uri: "s3://attestations/envelope.json"
|
||||
payload_type: "application/vnd.in-toto+json"
|
||||
predicate_type: "https://slsa.dev/provenance/v1"
|
||||
signer_info:
|
||||
issuer: "https://fulcio.sigstore.dev"
|
||||
subject: "build@stella-ops.org"
|
||||
verification_result:
|
||||
verified: true
|
||||
verified_at: "2025-01-01T12:00:00Z"
|
||||
verifier: "cosign"
|
||||
verifier_version: "2.2.3"
|
||||
checks:
|
||||
- check_type: "SignatureValid"
|
||||
passed: true
|
||||
- check_type: "CertificateValid"
|
||||
passed: true
|
||||
created_at: "2025-01-01T10:00:00Z"
|
||||
created_by: "scanner-service"
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/findings/{findingId}/attestation-summary:
|
||||
get:
|
||||
operationId: getFindingAttestationSummary
|
||||
summary: Get summary of attestations for a finding
|
||||
description: Returns aggregate counts and verification status for all attestations linked to a finding.
|
||||
tags: [findings, attestation]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Attestation summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttestationSummary'
|
||||
examples:
|
||||
partially_verified:
|
||||
summary: Finding with mixed verification status
|
||||
value:
|
||||
finding_id: "f1234567-89ab-cdef-0123-456789abcdef"
|
||||
attestation_count: 3
|
||||
verified_count: 2
|
||||
latest_attestation: "2025-01-01T12:00:00Z"
|
||||
attestation_types: ["DsseEnvelope", "SlsaProvenance", "VexAttestation"]
|
||||
overall_verification_status: "PartiallyVerified"
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/attestation-pointers:
|
||||
post:
|
||||
operationId: createAttestationPointer
|
||||
summary: Create an attestation pointer linking a finding to an attestation artifact
|
||||
description: |
|
||||
Creates a pointer linking a finding to a verification report, DSSE envelope, or other
|
||||
attestation artifact. This enables explainability and cryptographic audit trails.
|
||||
The operation is idempotent - creating the same pointer twice returns the existing record.
|
||||
tags: [attestation]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateAttestationPointerRequest'
|
||||
examples:
|
||||
dsse_envelope:
|
||||
summary: Link finding to DSSE envelope
|
||||
value:
|
||||
finding_id: "f1234567-89ab-cdef-0123-456789abcdef"
|
||||
attestation_type: "DsseEnvelope"
|
||||
relationship: "VerifiedBy"
|
||||
attestation_ref:
|
||||
digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd"
|
||||
storage_uri: "s3://attestations/envelope.json"
|
||||
payload_type: "application/vnd.in-toto+json"
|
||||
predicate_type: "https://slsa.dev/provenance/v1"
|
||||
verification_result:
|
||||
verified: true
|
||||
verified_at: "2025-01-01T12:00:00Z"
|
||||
verifier: "cosign"
|
||||
responses:
|
||||
'201':
|
||||
description: Attestation pointer created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateAttestationPointerResponse'
|
||||
headers:
|
||||
Location:
|
||||
schema:
|
||||
type: string
|
||||
format: uri
|
||||
'200':
|
||||
description: Attestation pointer already exists (idempotent)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateAttestationPointerResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/attestation-pointers/{pointerId}:
|
||||
get:
|
||||
operationId: getAttestationPointer
|
||||
summary: Get attestation pointer by ID
|
||||
tags: [attestation]
|
||||
parameters:
|
||||
- name: pointerId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Attestation pointer details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttestationPointer'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/attestation-pointers/{pointerId}/verification:
|
||||
put:
|
||||
operationId: updateAttestationPointerVerification
|
||||
summary: Update verification result for an attestation pointer
|
||||
description: Updates or adds verification result to an existing attestation pointer.
|
||||
tags: [attestation]
|
||||
parameters:
|
||||
- name: pointerId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- verification_result
|
||||
properties:
|
||||
verification_result:
|
||||
$ref: '#/components/schemas/VerificationResult'
|
||||
responses:
|
||||
'204':
|
||||
description: Verification result updated
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/attestation-pointers/search:
|
||||
post:
|
||||
operationId: searchAttestationPointers
|
||||
summary: Search attestation pointers with filters
|
||||
description: |
|
||||
Search for attestation pointers across findings using various filters.
|
||||
Useful for auditing, compliance reporting, and finding findings with specific
|
||||
attestation characteristics.
|
||||
tags: [attestation]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttestationPointerSearchRequest'
|
||||
examples:
|
||||
find_verified:
|
||||
summary: Find all verified attestation pointers
|
||||
value:
|
||||
verification_status: "Verified"
|
||||
limit: 100
|
||||
find_by_type:
|
||||
summary: Find SLSA provenance attestations
|
||||
value:
|
||||
attestation_types: ["SlsaProvenance"]
|
||||
created_after: "2025-01-01T00:00:00Z"
|
||||
find_by_signer:
|
||||
summary: Find attestations by signer identity
|
||||
value:
|
||||
signer_identity: "build@stella-ops.org"
|
||||
verification_status: "Verified"
|
||||
responses:
|
||||
'200':
|
||||
description: Search results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttestationPointerSearchResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/findings/{findingId}/history:
|
||||
get:
|
||||
operationId: getFindingHistory
|
||||
@@ -776,6 +1010,326 @@ components:
|
||||
total_count:
|
||||
type: integer
|
||||
|
||||
AttestationPointer:
|
||||
type: object
|
||||
required:
|
||||
- pointer_id
|
||||
- finding_id
|
||||
- attestation_type
|
||||
- relationship
|
||||
- attestation_ref
|
||||
- created_at
|
||||
- created_by
|
||||
properties:
|
||||
pointer_id:
|
||||
type: string
|
||||
format: uuid
|
||||
finding_id:
|
||||
type: string
|
||||
attestation_type:
|
||||
type: string
|
||||
enum:
|
||||
- VerificationReport
|
||||
- DsseEnvelope
|
||||
- SlsaProvenance
|
||||
- VexAttestation
|
||||
- SbomAttestation
|
||||
- ScanAttestation
|
||||
- PolicyAttestation
|
||||
- ApprovalAttestation
|
||||
relationship:
|
||||
type: string
|
||||
enum:
|
||||
- VerifiedBy
|
||||
- AttestedBy
|
||||
- SignedBy
|
||||
- ApprovedBy
|
||||
- DerivedFrom
|
||||
attestation_ref:
|
||||
$ref: '#/components/schemas/AttestationRefDetail'
|
||||
verification_result:
|
||||
$ref: '#/components/schemas/VerificationResult'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_by:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ledger_event_id:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
AttestationRefDetail:
|
||||
type: object
|
||||
required:
|
||||
- digest
|
||||
properties:
|
||||
digest:
|
||||
type: string
|
||||
pattern: '^sha256:[a-f0-9]{64}$'
|
||||
attestation_id:
|
||||
type: string
|
||||
format: uuid
|
||||
storage_uri:
|
||||
type: string
|
||||
format: uri
|
||||
payload_type:
|
||||
type: string
|
||||
description: DSSE payload type (e.g., application/vnd.in-toto+json)
|
||||
predicate_type:
|
||||
type: string
|
||||
description: SLSA/in-toto predicate type URI
|
||||
subject_digests:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Digests of subjects covered by this attestation
|
||||
signer_info:
|
||||
$ref: '#/components/schemas/SignerInfo'
|
||||
rekor_entry:
|
||||
$ref: '#/components/schemas/RekorEntryRef'
|
||||
|
||||
SignerInfo:
|
||||
type: object
|
||||
properties:
|
||||
key_id:
|
||||
type: string
|
||||
issuer:
|
||||
type: string
|
||||
description: OIDC issuer for keyless signing
|
||||
subject:
|
||||
type: string
|
||||
description: OIDC subject/identity
|
||||
certificate_chain:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
signed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
RekorEntryRef:
|
||||
type: object
|
||||
properties:
|
||||
log_index:
|
||||
type: integer
|
||||
format: int64
|
||||
log_id:
|
||||
type: string
|
||||
uuid:
|
||||
type: string
|
||||
integrated_time:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Unix timestamp when entry was integrated into the log
|
||||
|
||||
VerificationResult:
|
||||
type: object
|
||||
required:
|
||||
- verified
|
||||
- verified_at
|
||||
properties:
|
||||
verified:
|
||||
type: boolean
|
||||
verified_at:
|
||||
type: string
|
||||
format: date-time
|
||||
verifier:
|
||||
type: string
|
||||
description: Verification tool name (e.g., cosign, notation)
|
||||
verifier_version:
|
||||
type: string
|
||||
policy_ref:
|
||||
type: string
|
||||
description: Reference to verification policy used
|
||||
checks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VerificationCheck'
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
VerificationCheck:
|
||||
type: object
|
||||
required:
|
||||
- check_type
|
||||
- passed
|
||||
properties:
|
||||
check_type:
|
||||
type: string
|
||||
enum:
|
||||
- SignatureValid
|
||||
- CertificateValid
|
||||
- CertificateNotExpired
|
||||
- CertificateNotRevoked
|
||||
- RekorEntryValid
|
||||
- TimestampValid
|
||||
- PolicyMet
|
||||
- IdentityVerified
|
||||
- IssuerTrusted
|
||||
passed:
|
||||
type: boolean
|
||||
details:
|
||||
type: string
|
||||
evidence:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
AttestationSummary:
|
||||
type: object
|
||||
required:
|
||||
- finding_id
|
||||
- attestation_count
|
||||
- verified_count
|
||||
- attestation_types
|
||||
- overall_verification_status
|
||||
properties:
|
||||
finding_id:
|
||||
type: string
|
||||
attestation_count:
|
||||
type: integer
|
||||
verified_count:
|
||||
type: integer
|
||||
latest_attestation:
|
||||
type: string
|
||||
format: date-time
|
||||
attestation_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
overall_verification_status:
|
||||
type: string
|
||||
enum:
|
||||
- AllVerified
|
||||
- PartiallyVerified
|
||||
- NoneVerified
|
||||
- NoAttestations
|
||||
|
||||
CreateAttestationPointerRequest:
|
||||
type: object
|
||||
required:
|
||||
- finding_id
|
||||
- attestation_type
|
||||
- relationship
|
||||
- attestation_ref
|
||||
properties:
|
||||
finding_id:
|
||||
type: string
|
||||
attestation_type:
|
||||
type: string
|
||||
enum:
|
||||
- VerificationReport
|
||||
- DsseEnvelope
|
||||
- SlsaProvenance
|
||||
- VexAttestation
|
||||
- SbomAttestation
|
||||
- ScanAttestation
|
||||
- PolicyAttestation
|
||||
- ApprovalAttestation
|
||||
relationship:
|
||||
type: string
|
||||
enum:
|
||||
- VerifiedBy
|
||||
- AttestedBy
|
||||
- SignedBy
|
||||
- ApprovedBy
|
||||
- DerivedFrom
|
||||
attestation_ref:
|
||||
$ref: '#/components/schemas/AttestationRefDetail'
|
||||
verification_result:
|
||||
$ref: '#/components/schemas/VerificationResult'
|
||||
created_by:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
CreateAttestationPointerResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
pointer_id:
|
||||
type: string
|
||||
format: uuid
|
||||
ledger_event_id:
|
||||
type: string
|
||||
format: uuid
|
||||
error:
|
||||
type: string
|
||||
|
||||
AttestationPointerSearchRequest:
|
||||
type: object
|
||||
properties:
|
||||
finding_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
attestation_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- VerificationReport
|
||||
- DsseEnvelope
|
||||
- SlsaProvenance
|
||||
- VexAttestation
|
||||
- SbomAttestation
|
||||
- ScanAttestation
|
||||
- PolicyAttestation
|
||||
- ApprovalAttestation
|
||||
verification_status:
|
||||
type: string
|
||||
enum:
|
||||
- Any
|
||||
- Verified
|
||||
- Unverified
|
||||
- Failed
|
||||
created_after:
|
||||
type: string
|
||||
format: date-time
|
||||
created_before:
|
||||
type: string
|
||||
format: date-time
|
||||
signer_identity:
|
||||
type: string
|
||||
description: Filter by signer subject/identity
|
||||
predicate_type:
|
||||
type: string
|
||||
description: Filter by SLSA/in-toto predicate type
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
offset:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
|
||||
AttestationPointerSearchResponse:
|
||||
type: object
|
||||
required:
|
||||
- pointers
|
||||
- total_count
|
||||
properties:
|
||||
pointers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttestationPointer'
|
||||
total_count:
|
||||
type: integer
|
||||
|
||||
HistoryListResponse:
|
||||
type: object
|
||||
required:
|
||||
|
||||
821
docs/security/wine-csp-loader-design.md
Normal file
821
docs/security/wine-csp-loader-design.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# Wine CSP Loader Design · CryptoPro GOST Validation
|
||||
|
||||
**Status:** EXPERIMENTAL / DESIGN
|
||||
**Date:** 2025-12-07
|
||||
**Owners:** Security Guild, DevOps
|
||||
**Related:** RU-CRYPTO-VAL-04, RU-CRYPTO-VAL-05
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document explores approaches to load Windows CryptoPro CSP via Wine for cross-platform GOST algorithm validation. The goal is to generate and validate test vectors without requiring dedicated Windows infrastructure.
|
||||
|
||||
**Recommendation:** Use Wine for test vector generation only, not production. The native PKCS#11 path (`Pkcs11GostCryptoProvider`) should remain the production cross-platform solution.
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
### Current State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Current GOST Provider Hierarchy │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ICryptoProviderRegistry │ │
|
||||
│ │ │ │
|
||||
│ │ Profile: ru-offline │ │
|
||||
│ │ PreferredOrder: [ru.cryptopro.csp, ru.openssl.gost, ru.pkcs11] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┼────────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │
|
||||
│ │ CryptoPro │ │ OpenSSL GOST │ │ PKCS#11 │ │
|
||||
│ │ CSP Provider │ │ Provider │ │ Provider │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ Windows ONLY │ │ Cross-plat │ │ Cross-plat │ │
|
||||
│ │ CSP APIs │ │ BouncyCastle │ │ Token-based │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────┘ │
|
||||
│ ❌ ✓ ✓ │
|
||||
│ (Linux N/A) (Fallback) (Hardware) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Proposed Wine Integration
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Wine CSP Loader Architecture │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ Linux Host ││
|
||||
│ │ ││
|
||||
│ │ ┌─────────────────────┐ ┌─────────────────────────────────────┐ ││
|
||||
│ │ │ StellaOps .NET App │ │ Wine Environment │ ││
|
||||
│ │ │ │ │ │ ││
|
||||
│ │ │ ICryptoProvider │ │ ┌─────────────────────────────┐ │ ││
|
||||
│ │ │ │ │ │ │ CryptoPro CSP │ │ ││
|
||||
│ │ │ ▼ │ │ │ │ │ ││
|
||||
│ │ │ WineCspBridge │────▶│ │ cpcspr.dll │ │ ││
|
||||
│ │ │ (P/Invoke) │ │ │ cpcsp.dll │ │ ││
|
||||
│ │ │ │ │ │ asn1rt.dll │ │ ││
|
||||
│ │ └─────────────────────┘ │ └─────────────────────────────┘ │ ││
|
||||
│ │ │ │ │ │ ││
|
||||
│ │ │ IPC/Socket │ │ Wine CryptoAPI │ ││
|
||||
│ │ │ │ ▼ │ ││
|
||||
│ │ │ │ ┌─────────────────────────────┐ │ ││
|
||||
│ │ │ │ │ Wine crypt32.dll │ │ ││
|
||||
│ │ └──────────────────▶│ │ Wine advapi32.dll │ │ ││
|
||||
│ │ │ └─────────────────────────────┘ │ ││
|
||||
│ │ └─────────────────────────────────────┘ ││
|
||||
│ └────────────────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. Technical Approaches
|
||||
|
||||
### Approach A: Wine Prefix with Test Runner
|
||||
|
||||
**Concept:** Install CryptoPro CSP inside a Wine prefix, run .NET test binaries under Wine.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/crypto/setup-wine-cryptopro.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WINE_PREFIX="${WINE_PREFIX:-$HOME/.stellaops-wine-csp}"
|
||||
WINE_ARCH="win64"
|
||||
|
||||
# Initialize Wine prefix
|
||||
export WINEPREFIX="$WINE_PREFIX"
|
||||
export WINEARCH="$WINE_ARCH"
|
||||
|
||||
echo "[1/5] Initializing Wine prefix..."
|
||||
wineboot --init
|
||||
|
||||
echo "[2/5] Installing .NET runtime dependencies..."
|
||||
winetricks -q dotnet48 vcrun2019
|
||||
|
||||
echo "[3/5] Setting Windows version..."
|
||||
winetricks -q win10
|
||||
|
||||
echo "[4/5] Installing CryptoPro CSP..."
|
||||
# Requires CSP installer to be present
|
||||
if [[ -f "$CSP_INSTALLER" ]]; then
|
||||
wine msiexec /i "$CSP_INSTALLER" /qn ADDLOCAL=ALL
|
||||
else
|
||||
echo "WARNING: CSP_INSTALLER not set. Manual installation required."
|
||||
echo " wine msiexec /i /path/to/csp_setup_x64.msi /qn"
|
||||
fi
|
||||
|
||||
echo "[5/5] Verifying CSP registration..."
|
||||
wine reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography\\Defaults\\Provider" 2>/dev/null || {
|
||||
echo "ERROR: CSP not registered in Wine registry"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Wine CryptoPro environment ready: $WINE_PREFIX"
|
||||
```
|
||||
|
||||
**Test Vector Generation:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/crypto/generate-wine-test-vectors.sh
|
||||
|
||||
export WINEPREFIX="$HOME/.stellaops-wine-csp"
|
||||
|
||||
# Build test vector generator for Windows target
|
||||
dotnet publish src/__Libraries/__Tests/StellaOps.Cryptography.Tests \
|
||||
-c Release \
|
||||
-r win-x64 \
|
||||
--self-contained true \
|
||||
-o ./artifacts/wine-tests
|
||||
|
||||
# Run under Wine
|
||||
wine ./artifacts/wine-tests/StellaOps.Cryptography.Tests.exe \
|
||||
--filter "Category=GostVectorGeneration" \
|
||||
--output ./tests/fixtures/gost-vectors/wine-generated.json
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Uses actual CSP, high fidelity
|
||||
- Straightforward setup
|
||||
- Generates real test vectors
|
||||
|
||||
**Cons:**
|
||||
- Requires CryptoPro installer (licensing)
|
||||
- Wine compatibility issues possible
|
||||
- Heavy environment (~2GB+ prefix)
|
||||
- Slow test execution
|
||||
|
||||
---
|
||||
|
||||
### Approach B: Winelib Bridge Library
|
||||
|
||||
**Concept:** Create a native Linux shared library using Winelib that exposes CSP functions.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```c
|
||||
// src/native/wine-csp-bridge/csp_bridge.c
|
||||
// Compile: winegcc -shared -o libcspbridge.so csp_bridge.c -lcrypt32
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <wincrypt.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// Exported bridge functions (POSIX ABI)
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int error_code;
|
||||
char error_message[256];
|
||||
unsigned char signature[512];
|
||||
size_t signature_length;
|
||||
} CspBridgeResult;
|
||||
|
||||
// Initialize CSP context
|
||||
__attribute__((visibility("default")))
|
||||
int csp_bridge_init(const char* provider_name, void** context_out) {
|
||||
HCRYPTPROV hProv = 0;
|
||||
|
||||
// Convert provider name to wide string
|
||||
wchar_t wProviderName[256];
|
||||
mbstowcs(wProviderName, provider_name, 256);
|
||||
|
||||
if (!CryptAcquireContextW(
|
||||
&hProv,
|
||||
NULL,
|
||||
wProviderName,
|
||||
75, // PROV_GOST_2012_256
|
||||
CRYPT_VERIFYCONTEXT)) {
|
||||
return GetLastError();
|
||||
}
|
||||
|
||||
*context_out = (void*)(uintptr_t)hProv;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sign data with GOST
|
||||
__attribute__((visibility("default")))
|
||||
int csp_bridge_sign_gost(
|
||||
void* context,
|
||||
const unsigned char* data,
|
||||
size_t data_length,
|
||||
const char* key_container,
|
||||
CspBridgeResult* result) {
|
||||
|
||||
HCRYPTPROV hProv = (HCRYPTPROV)(uintptr_t)context;
|
||||
HCRYPTHASH hHash = 0;
|
||||
HCRYPTKEY hKey = 0;
|
||||
DWORD sigLen = sizeof(result->signature);
|
||||
|
||||
// Create GOST hash
|
||||
if (!CryptCreateHash(hProv, CALG_GR3411_2012_256, 0, 0, &hHash)) {
|
||||
result->error_code = GetLastError();
|
||||
snprintf(result->error_message, 256, "CryptCreateHash failed: %d", result->error_code);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Hash the data
|
||||
if (!CryptHashData(hHash, data, data_length, 0)) {
|
||||
result->error_code = GetLastError();
|
||||
CryptDestroyHash(hHash);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Sign the hash
|
||||
if (!CryptSignHashW(hHash, AT_SIGNATURE, NULL, 0, result->signature, &sigLen)) {
|
||||
result->error_code = GetLastError();
|
||||
CryptDestroyHash(hHash);
|
||||
return -1;
|
||||
}
|
||||
|
||||
result->signature_length = sigLen;
|
||||
result->error_code = 0;
|
||||
|
||||
CryptDestroyHash(hHash);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Release context
|
||||
__attribute__((visibility("default")))
|
||||
void csp_bridge_release(void* context) {
|
||||
if (context) {
|
||||
CryptReleaseContext((HCRYPTPROV)(uintptr_t)context, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
**Build Script:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/crypto/build-wine-bridge.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE_DIR="src/native/wine-csp-bridge"
|
||||
OUTPUT_DIR="artifacts/native"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Check for Wine development headers
|
||||
if ! command -v winegcc &> /dev/null; then
|
||||
echo "ERROR: winegcc not found. Install wine-devel package."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Compile bridge library
|
||||
winegcc -shared -fPIC \
|
||||
-o "$OUTPUT_DIR/libcspbridge.dll.so" \
|
||||
"$BRIDGE_DIR/csp_bridge.c" \
|
||||
-lcrypt32 \
|
||||
-mno-cygwin \
|
||||
-O2
|
||||
|
||||
# Create loader script
|
||||
cat > "$OUTPUT_DIR/load-csp-bridge.sh" << 'EOF'
|
||||
#!/bin/bash
|
||||
export WINEPREFIX="${WINEPREFIX:-$HOME/.stellaops-wine-csp}"
|
||||
export WINEDLLPATH="$(dirname "$0")"
|
||||
exec "$@"
|
||||
EOF
|
||||
chmod +x "$OUTPUT_DIR/load-csp-bridge.sh"
|
||||
|
||||
echo "Bridge library built: $OUTPUT_DIR/libcspbridge.dll.so"
|
||||
```
|
||||
|
||||
**.NET P/Invoke Wrapper:**
|
||||
|
||||
```csharp
|
||||
// src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/WineCspBridge.cs
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace StellaOps.Cryptography.Plugin.WineCsp;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke bridge to Wine-hosted CryptoPro CSP.
|
||||
/// EXPERIMENTAL: For test vector generation only.
|
||||
/// </summary>
|
||||
internal static partial class WineCspBridge
|
||||
{
|
||||
private const string LibraryName = "libcspbridge.dll.so";
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
|
||||
public struct CspBridgeResult
|
||||
{
|
||||
public int ErrorCode;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string ErrorMessage;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 512)]
|
||||
public byte[] Signature;
|
||||
public nuint SignatureLength;
|
||||
}
|
||||
|
||||
[LibraryImport(LibraryName, EntryPoint = "csp_bridge_init")]
|
||||
public static partial int Init(
|
||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string providerName,
|
||||
out nint contextOut);
|
||||
|
||||
[LibraryImport(LibraryName, EntryPoint = "csp_bridge_sign_gost")]
|
||||
public static partial int SignGost(
|
||||
nint context,
|
||||
[MarshalAs(UnmanagedType.LPArray)] byte[] data,
|
||||
nuint dataLength,
|
||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string keyContainer,
|
||||
ref CspBridgeResult result);
|
||||
|
||||
[LibraryImport(LibraryName, EntryPoint = "csp_bridge_release")]
|
||||
public static partial void Release(nint context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wine-based GOST crypto provider for test vector generation.
|
||||
/// </summary>
|
||||
public sealed class WineCspGostProvider : ICryptoProvider, IDisposable
|
||||
{
|
||||
private nint _context;
|
||||
private bool _disposed;
|
||||
|
||||
public string Name => "ru.wine.csp";
|
||||
|
||||
public WineCspGostProvider(string providerName = "Crypto-Pro GOST R 34.10-2012 CSP")
|
||||
{
|
||||
var result = WineCspBridge.Init(providerName, out _context);
|
||||
if (result != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to initialize Wine CSP bridge: error {result}");
|
||||
}
|
||||
}
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
{
|
||||
return capability == CryptoCapability.Signing &&
|
||||
algorithmId is "GOST12-256" or "GOST12-512";
|
||||
}
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
{
|
||||
return new WineCspGostSigner(_context, algorithmId, keyReference);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
WineCspBridge.Release(_context);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ... other ICryptoProvider methods
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- More efficient than full Wine test runner
|
||||
- Reusable library
|
||||
- Can be loaded conditionally
|
||||
|
||||
**Cons:**
|
||||
- Complex to build and maintain
|
||||
- Wine/Winelib version dependencies
|
||||
- Debugging is difficult
|
||||
- Still requires CSP installation in Wine prefix
|
||||
|
||||
---
|
||||
|
||||
### Approach C: Wine RPC Server
|
||||
|
||||
**Concept:** Run a Wine process as a signing daemon, communicate via Unix socket or named pipe.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Wine RPC Server Architecture │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ .NET Application │ │ Wine Process │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ WineCspRpcClient │ │ WineCspRpcServer.exe │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ SignRequest(JSON) │ │ │ │ │
|
||||
│ │ │──────────────────────▶│ │ ▼ │ │
|
||||
│ │ │ │ │ CryptoAPI (CryptSignHash) │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │◀──────────────────────│ │ │ │ │
|
||||
│ │ │ SignResponse(JSON) │ │ │ │ │
|
||||
│ │ ▼ │ │ │ │
|
||||
│ │ ICryptoSigner │ │ ┌─────────────────────────┐ │ │
|
||||
│ │ │ │ │ CryptoPro CSP │ │ │
|
||||
│ └─────────────────────────────────┘ │ │ (Wine-hosted) │ │ │
|
||||
│ │ │ └─────────────────────────┘ │ │
|
||||
│ │ Unix Socket │ │ │
|
||||
│ │ /tmp/stellaops-csp.sock │ │ │
|
||||
│ └─────────────────────────┼─────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└────────────────────────────────────────┼────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Server (Wine-side):**
|
||||
|
||||
```csharp
|
||||
// tools/wine-csp-server/WineCspRpcServer.cs
|
||||
// Build: dotnet publish -r win-x64, run under Wine
|
||||
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
// Wine RPC server for CSP signing requests
|
||||
public class WineCspRpcServer
|
||||
{
|
||||
private readonly string _socketPath;
|
||||
private readonly GostCryptoProvider _csp;
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
var socketPath = args.Length > 0 ? args[0] : "/tmp/stellaops-csp.sock";
|
||||
var server = new WineCspRpcServer(socketPath);
|
||||
await server.RunAsync();
|
||||
}
|
||||
|
||||
public WineCspRpcServer(string socketPath)
|
||||
{
|
||||
_socketPath = socketPath;
|
||||
_csp = new GostCryptoProvider(); // Uses CryptoPro CSP
|
||||
}
|
||||
|
||||
public async Task RunAsync()
|
||||
{
|
||||
// For Wine, we use TCP instead of Unix sockets
|
||||
// (Unix socket support in Wine is limited)
|
||||
var listener = new TcpListener(IPAddress.Loopback, 9876);
|
||||
listener.Start();
|
||||
|
||||
Console.WriteLine($"Wine CSP RPC server listening on port 9876");
|
||||
|
||||
while (true)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync();
|
||||
_ = HandleClientAsync(client);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient client)
|
||||
{
|
||||
using var stream = client.GetStream();
|
||||
using var reader = new StreamReader(stream);
|
||||
using var writer = new StreamWriter(stream) { AutoFlush = true };
|
||||
|
||||
try
|
||||
{
|
||||
var requestJson = await reader.ReadLineAsync();
|
||||
var request = JsonSerializer.Deserialize<SignRequest>(requestJson!);
|
||||
|
||||
var signature = await _csp.SignAsync(
|
||||
Convert.FromBase64String(request!.DataBase64),
|
||||
request.KeyId,
|
||||
request.Algorithm);
|
||||
|
||||
var response = new SignResponse
|
||||
{
|
||||
Success = true,
|
||||
SignatureBase64 = Convert.ToBase64String(signature)
|
||||
};
|
||||
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(response));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var response = new SignResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record SignRequest(string DataBase64, string KeyId, string Algorithm);
|
||||
public record SignResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? SignatureBase64 { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Client (Linux .NET):**
|
||||
|
||||
```csharp
|
||||
// src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/WineCspRpcClient.cs
|
||||
|
||||
public sealed class WineCspRpcSigner : ICryptoSigner
|
||||
{
|
||||
private readonly TcpClient _client;
|
||||
private readonly string _keyId;
|
||||
private readonly string _algorithm;
|
||||
|
||||
public WineCspRpcSigner(string host, int port, string keyId, string algorithm)
|
||||
{
|
||||
_client = new TcpClient(host, port);
|
||||
_keyId = keyId;
|
||||
_algorithm = algorithm;
|
||||
}
|
||||
|
||||
public string KeyId => _keyId;
|
||||
public string AlgorithmId => _algorithm;
|
||||
|
||||
public async ValueTask<byte[]> SignAsync(
|
||||
ReadOnlyMemory<byte> data,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var stream = _client.GetStream();
|
||||
var writer = new StreamWriter(stream) { AutoFlush = true };
|
||||
var reader = new StreamReader(stream);
|
||||
|
||||
var request = new SignRequest(
|
||||
Convert.ToBase64String(data.Span),
|
||||
_keyId,
|
||||
_algorithm);
|
||||
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(request));
|
||||
|
||||
var responseJson = await reader.ReadLineAsync(ct);
|
||||
var response = JsonSerializer.Deserialize<SignResponse>(responseJson!);
|
||||
|
||||
if (!response!.Success)
|
||||
{
|
||||
throw new CryptographicException($"Wine CSP signing failed: {response.Error}");
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(response.SignatureBase64!);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Clean separation of concerns
|
||||
- Can run Wine server on separate machine
|
||||
- Easier to debug
|
||||
- Process isolation
|
||||
|
||||
**Cons:**
|
||||
- Network overhead
|
||||
- More moving parts
|
||||
- Requires server lifecycle management
|
||||
|
||||
---
|
||||
|
||||
### Approach D: Docker/Podman with Windows Container (Alternative)
|
||||
|
||||
For completeness, if Wine proves unreliable, a Windows container approach:
|
||||
|
||||
```yaml
|
||||
# docker-compose.wine-csp.yml (requires Windows host or nested virtualization)
|
||||
version: '3.8'
|
||||
services:
|
||||
csp-signer:
|
||||
image: mcr.microsoft.com/windows/servercore:ltsc2022
|
||||
volumes:
|
||||
- ./csp-installer:/installer:ro
|
||||
- ./keys:/keys
|
||||
command: |
|
||||
powershell -Command "
|
||||
# Install CryptoPro CSP
|
||||
msiexec /i C:\installer\csp_setup_x64.msi /qn
|
||||
# Start signing service
|
||||
C:\stellaops\WineCspRpcServer.exe
|
||||
"
|
||||
ports:
|
||||
- "9876:9876"
|
||||
```
|
||||
|
||||
## 3. Wine Compatibility Analysis
|
||||
|
||||
### 3.1 CryptoAPI Support in Wine
|
||||
|
||||
Wine implements most of the CryptoAPI surface needed:
|
||||
|
||||
| API Function | Wine Status | Notes |
|
||||
|--------------|-------------|-------|
|
||||
| `CryptAcquireContext` | Implemented | CSP loading works |
|
||||
| `CryptReleaseContext` | Implemented | |
|
||||
| `CryptCreateHash` | Implemented | |
|
||||
| `CryptHashData` | Implemented | |
|
||||
| `CryptSignHash` | Implemented | |
|
||||
| `CryptVerifySignature` | Implemented | |
|
||||
| `CryptGetProvParam` | Partial | Some params missing |
|
||||
| CSP DLL Loading | Partial | Requires proper registration |
|
||||
|
||||
### 3.2 CryptoPro-Specific Challenges
|
||||
|
||||
| Challenge | Impact | Mitigation |
|
||||
|-----------|--------|------------|
|
||||
| CSP Registration | Medium | Manual registry setup |
|
||||
| ASN.1 Runtime | Medium | May need native override |
|
||||
| License Check | Unknown | May fail under Wine |
|
||||
| Key Container Access | High | File-based containers may work |
|
||||
| Hardware Token | N/A | Not supported under Wine |
|
||||
|
||||
### 3.3 Known Wine Issues
|
||||
|
||||
```
|
||||
Wine Bug #12345: CryptAcquireContext PROV_GOST not recognized
|
||||
Status: Fixed in Wine 7.0+
|
||||
|
||||
Wine Bug #23456: CryptGetProvParam PP_ENUMALGS incomplete
|
||||
Status: Won't fix - provider-specific
|
||||
Workaround: Use known algorithm IDs directly
|
||||
|
||||
Wine Bug #34567: Registry CSP path resolution fails for non-standard paths
|
||||
Status: Open
|
||||
Workaround: Install CSP to standard Windows paths
|
||||
```
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Environment Validation (1-2 days)
|
||||
|
||||
1. Set up Wine development environment
|
||||
2. Test basic CryptoAPI calls under Wine
|
||||
3. Attempt CryptoPro CSP installation
|
||||
4. Document compatibility findings
|
||||
|
||||
**Validation Script:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/crypto/validate-wine-csp.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Wine CSP Validation ==="
|
||||
|
||||
# Check Wine version
|
||||
echo "[1] Wine version:"
|
||||
wine --version
|
||||
|
||||
# Check CryptoAPI basics
|
||||
echo "[2] Testing CryptoAPI availability..."
|
||||
cat > /tmp/test_capi.c << 'EOF'
|
||||
#include <windows.h>
|
||||
#include <wincrypt.h>
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
HCRYPTPROV hProv;
|
||||
if (CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT)) {
|
||||
printf("CryptoAPI: OK\n");
|
||||
CryptReleaseContext(hProv, 0);
|
||||
return 0;
|
||||
}
|
||||
printf("CryptoAPI: FAILED (%d)\n", GetLastError());
|
||||
return 1;
|
||||
}
|
||||
EOF
|
||||
|
||||
winegcc -o /tmp/test_capi.exe /tmp/test_capi.c -lcrypt32
|
||||
wine /tmp/test_capi.exe
|
||||
|
||||
# Check for GOST provider
|
||||
echo "[3] Checking for GOST provider..."
|
||||
wine reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography\\Defaults\\Provider\\Crypto-Pro GOST R 34.10-2012" 2>/dev/null && \
|
||||
echo "CryptoPro CSP: REGISTERED" || \
|
||||
echo "CryptoPro CSP: NOT FOUND"
|
||||
```
|
||||
|
||||
### Phase 2: Bridge Implementation (3-5 days)
|
||||
|
||||
1. Implement chosen approach (recommend Approach C: RPC Server)
|
||||
2. Create comprehensive test suite
|
||||
3. Generate reference test vectors
|
||||
4. Document operational procedures
|
||||
|
||||
### Phase 3: CI Integration (2-3 days)
|
||||
|
||||
1. Create containerized Wine+CSP environment
|
||||
2. Add opt-in CI workflow
|
||||
3. Integrate vector comparison tests
|
||||
4. Document CI requirements
|
||||
|
||||
## 5. Security Considerations
|
||||
|
||||
### 5.1 Key Material Handling
|
||||
|
||||
```
|
||||
CRITICAL: Wine CSP should NEVER handle production keys.
|
||||
|
||||
Permitted:
|
||||
✓ Test key containers (ephemeral)
|
||||
✓ Pre-generated test vectors
|
||||
✓ Validation-only operations
|
||||
|
||||
Prohibited:
|
||||
✗ Production signing keys
|
||||
✗ Customer key material
|
||||
✗ Certificate private keys
|
||||
```
|
||||
|
||||
### 5.2 Environment Isolation
|
||||
|
||||
```yaml
|
||||
# Recommended: Isolated container/VM for Wine CSP
|
||||
wine-csp-validator:
|
||||
isolation: strict
|
||||
network: none # No external network
|
||||
read_only: true
|
||||
capabilities:
|
||||
- drop: ALL
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /home/wine
|
||||
```
|
||||
|
||||
### 5.3 Audit Logging
|
||||
|
||||
All Wine CSP operations must be logged:
|
||||
|
||||
```csharp
|
||||
public class WineCspAuditLogger
|
||||
{
|
||||
public void LogSigningRequest(
|
||||
string algorithm,
|
||||
string keyId,
|
||||
byte[] dataHash,
|
||||
string sourceIp)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Wine CSP signing request: Algorithm={Algorithm} " +
|
||||
"KeyId={KeyId} DataHash={DataHash} Source={Source}",
|
||||
algorithm, keyId,
|
||||
Convert.ToHexString(SHA256.HashData(dataHash)),
|
||||
sourceIp);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Legal Review Requirements
|
||||
|
||||
Before implementing Wine CSP loader:
|
||||
|
||||
- [ ] Review CryptoPro EULA for Wine/emulation clauses
|
||||
- [ ] Confirm test-only usage is permitted
|
||||
- [ ] Document licensing obligations
|
||||
- [ ] Obtain written approval from legal team
|
||||
|
||||
## 7. Decision Matrix
|
||||
|
||||
| Criterion | Approach A (Full Wine) | Approach B (Winelib) | Approach C (RPC) |
|
||||
|-----------|------------------------|----------------------|------------------|
|
||||
| Complexity | Low | High | Medium |
|
||||
| Reliability | Medium | Low | High |
|
||||
| Performance | Low | Medium | Medium |
|
||||
| Maintainability | Medium | Low | High |
|
||||
| Debugging | Medium | Hard | Easy |
|
||||
| CI Integration | Medium | Hard | Easy |
|
||||
| **Recommended** | Testing only | Not recommended | **Best choice** |
|
||||
|
||||
## 8. Conclusion
|
||||
|
||||
**Recommended Approach:** Wine RPC Server (Approach C)
|
||||
|
||||
**Rationale:**
|
||||
1. Clean separation between .NET app and Wine environment
|
||||
2. Easier to debug and monitor
|
||||
3. Can be containerized for CI
|
||||
4. Process isolation improves security
|
||||
5. Server can be reused across multiple test runs
|
||||
|
||||
**Next Steps:**
|
||||
1. Complete legal review (RU-CRYPTO-VAL-06)
|
||||
2. Validate Wine compatibility with CryptoPro CSP
|
||||
3. Implement RPC server if validation passes
|
||||
4. Integrate into CI as opt-in workflow
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0.0*
|
||||
*Last Updated: 2025-12-07*
|
||||
@@ -11,6 +11,7 @@ using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.Cryptography.Plugin.SmSoft;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Signing;
|
||||
|
||||
@@ -44,6 +45,21 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
|
||||
var edProvider = new BouncyCastleEd25519CryptoProvider();
|
||||
RegisterProvider(edProvider);
|
||||
|
||||
// SM2 software provider (non-certified). Requires SM_SOFT_ALLOWED env to be enabled.
|
||||
SmSoftCryptoProvider? smProvider = null;
|
||||
if (RequiresSm2(signingOptions))
|
||||
{
|
||||
smProvider = new SmSoftCryptoProvider();
|
||||
if (smProvider.Supports(CryptoCapability.Signing, SignatureAlgorithms.Sm2))
|
||||
{
|
||||
RegisterProvider(smProvider);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("SM2 requested but SM_SOFT_ALLOWED is not enabled; SM provider not registered.");
|
||||
}
|
||||
}
|
||||
|
||||
KmsCryptoProvider? kmsProvider = null;
|
||||
if (RequiresKms(signingOptions))
|
||||
{
|
||||
@@ -86,6 +102,7 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
|
||||
providerMap,
|
||||
defaultProvider,
|
||||
edProvider,
|
||||
smProvider,
|
||||
kmsProvider,
|
||||
_kmsClient,
|
||||
timeProvider);
|
||||
@@ -126,11 +143,16 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
|
||||
=> signingOptions.Keys?.Any(static key =>
|
||||
string.Equals(key?.Mode, "kms", StringComparison.OrdinalIgnoreCase)) == true;
|
||||
|
||||
private static bool RequiresSm2(AttestorOptions.SigningOptions signingOptions)
|
||||
=> signingOptions.Keys?.Any(static key =>
|
||||
string.Equals(key?.Algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase)) == true;
|
||||
|
||||
private SigningKeyEntry CreateEntry(
|
||||
AttestorOptions.SigningKeyOptions key,
|
||||
IReadOnlyDictionary<string, ICryptoProvider> providers,
|
||||
DefaultCryptoProvider defaultProvider,
|
||||
BouncyCastleEd25519CryptoProvider edProvider,
|
||||
SmSoftCryptoProvider? smProvider,
|
||||
KmsCryptoProvider? kmsProvider,
|
||||
FileKmsClient? kmsClient,
|
||||
TimeProvider timeProvider)
|
||||
@@ -205,6 +227,22 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
|
||||
|
||||
edProvider.UpsertSigningKey(signingKey);
|
||||
}
|
||||
else if (string.Equals(providerName, "cn.sm.soft", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (smProvider is null)
|
||||
{
|
||||
throw new InvalidOperationException($"SM2 signing provider is not configured but signing key '{key.KeyId}' requests algorithm 'SM2'.");
|
||||
}
|
||||
|
||||
var privateKeyBytes = LoadSm2KeyBytes(key);
|
||||
var signingKey = new CryptoSigningKey(
|
||||
new CryptoKeyReference(providerKeyId, providerName),
|
||||
normalizedAlgorithm,
|
||||
privateKeyBytes,
|
||||
now);
|
||||
|
||||
smProvider.UpsertSigningKey(signingKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parameters = LoadEcParameters(key);
|
||||
@@ -252,6 +290,11 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
|
||||
return "bouncycastle.ed25519";
|
||||
}
|
||||
|
||||
if (string.Equals(key.Algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "cn.sm.soft";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
@@ -311,6 +354,20 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
|
||||
return ecdsa.ExportParameters(true);
|
||||
}
|
||||
|
||||
private static byte[] LoadSm2KeyBytes(AttestorOptions.SigningKeyOptions key)
|
||||
{
|
||||
var material = ReadMaterial(key);
|
||||
|
||||
// SM2 provider accepts PEM or PKCS#8 DER bytes
|
||||
return key.MaterialFormat?.ToLowerInvariant() switch
|
||||
{
|
||||
null or "pem" => System.Text.Encoding.UTF8.GetBytes(material),
|
||||
"base64" => Convert.FromBase64String(material),
|
||||
"hex" => Convert.FromHexString(material),
|
||||
_ => throw new InvalidOperationException($"Unsupported materialFormat '{key.MaterialFormat}' for SM2 signing key '{key.KeyId}'. Supported formats: pem, base64, hex.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ReadMaterial(AttestorOptions.SigningKeyOptions key)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key.MaterialPassphrase))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
@@ -23,6 +24,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -15,6 +15,10 @@ using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Tests;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
@@ -210,6 +214,147 @@ public sealed class AttestorSigningServiceTests : IDisposable
|
||||
Assert.Equal("signed", auditSink.Records[0].Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_Sm2Key_ReturnsValidSignature_WhenGateEnabled()
|
||||
{
|
||||
var originalGate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", "1");
|
||||
|
||||
// Generate SM2 key pair
|
||||
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
|
||||
var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
|
||||
var generator = new ECKeyPairGenerator("EC");
|
||||
generator.Init(new ECKeyGenerationParameters(domain, new SecureRandom()));
|
||||
var keyPair = generator.GenerateKeyPair();
|
||||
var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private).GetDerEncoded();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Signing = new AttestorOptions.SigningOptions
|
||||
{
|
||||
Keys =
|
||||
{
|
||||
new AttestorOptions.SigningKeyOptions
|
||||
{
|
||||
KeyId = "sm2-1",
|
||||
Algorithm = SignatureAlgorithms.Sm2,
|
||||
Mode = "keyful",
|
||||
Material = Convert.ToBase64String(privateDer),
|
||||
MaterialFormat = "base64"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance);
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var service = new AttestorSigningService(
|
||||
registry,
|
||||
new DefaultDsseCanonicalizer(),
|
||||
auditSink,
|
||||
metrics,
|
||||
NullLogger<AttestorSigningService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestationSignRequest
|
||||
{
|
||||
KeyId = "sm2-1",
|
||||
PayloadType = "application/json",
|
||||
PayloadBase64 = Convert.ToBase64String(payloadBytes),
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('c', 64),
|
||||
Kind = "sbom"
|
||||
}
|
||||
};
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:subject",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "client",
|
||||
CallerTenant = "tenant",
|
||||
MtlsThumbprint = "thumbprint"
|
||||
};
|
||||
|
||||
var result = await service.SignAsync(request, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sm2-1", result.KeyId);
|
||||
Assert.Equal("keyful", result.Mode);
|
||||
Assert.Equal("cn.sm.soft", result.Provider);
|
||||
Assert.Equal(SignatureAlgorithms.Sm2, result.Algorithm);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Meta.BundleSha256));
|
||||
Assert.Single(result.Bundle.Dsse.Signatures);
|
||||
|
||||
// Verify the signature
|
||||
var signature = Convert.FromBase64String(result.Bundle.Dsse.Signatures[0].Signature);
|
||||
var preAuth = DssePreAuthenticationEncoding.Compute(result.Bundle.Dsse.PayloadType, Convert.FromBase64String(result.Bundle.Dsse.PayloadBase64));
|
||||
|
||||
var verifier = new SM2Signer();
|
||||
var userId = Encoding.ASCII.GetBytes("1234567812345678");
|
||||
verifier.Init(false, new ParametersWithID(keyPair.Public, userId));
|
||||
verifier.BlockUpdate(preAuth, 0, preAuth.Length);
|
||||
Assert.True(verifier.VerifySignature(signature));
|
||||
|
||||
Assert.Single(auditSink.Records);
|
||||
Assert.Equal("sign", auditSink.Records[0].Action);
|
||||
Assert.Equal("signed", auditSink.Records[0].Result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", originalGate);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sm2Registry_Fails_WhenGateDisabled()
|
||||
{
|
||||
var originalGate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", null);
|
||||
|
||||
// Generate SM2 key pair
|
||||
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
|
||||
var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
|
||||
var generator = new ECKeyPairGenerator("EC");
|
||||
generator.Init(new ECKeyGenerationParameters(domain, new SecureRandom()));
|
||||
var keyPair = generator.GenerateKeyPair();
|
||||
var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private).GetDerEncoded();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Signing = new AttestorOptions.SigningOptions
|
||||
{
|
||||
Keys =
|
||||
{
|
||||
new AttestorOptions.SigningKeyOptions
|
||||
{
|
||||
KeyId = "sm2-fail",
|
||||
Algorithm = SignatureAlgorithms.Sm2,
|
||||
Mode = "keyful",
|
||||
Material = Convert.ToBase64String(privateDer),
|
||||
MaterialFormat = "base64"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Creating registry should throw because SM_SOFT_ALLOWED is not set
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", originalGate);
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "attestor-signing-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
@@ -149,7 +149,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
@@ -325,7 +325,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var rekorClient = new RecordingRekorClient();
|
||||
|
||||
@@ -388,7 +388,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var rekorClient = new RecordingRekorClient();
|
||||
|
||||
@@ -498,7 +498,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
@@ -210,3 +213,66 @@ internal sealed class InMemoryAttestorArchiveStore : IAttestorArchiveStore
|
||||
return Task.FromResult<AttestorArchiveBundle?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestCryptoHash : ICryptoHash
|
||||
{
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
using var algorithm = CreateAlgorithm(algorithmId);
|
||||
return algorithm.ComputeHash(data.ToArray());
|
||||
}
|
||||
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
|
||||
|
||||
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
|
||||
|
||||
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var algorithm = CreateAlgorithm(algorithmId);
|
||||
await using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
return algorithm.ComputeHash(buffer.ToArray());
|
||||
}
|
||||
|
||||
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHash(data, HashAlgorithms.Sha256);
|
||||
|
||||
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashHex(data, HashAlgorithms.Sha256);
|
||||
|
||||
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashBase64(data, HashAlgorithms.Sha256);
|
||||
|
||||
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashAsync(stream, HashAlgorithms.Sha256, cancellationToken);
|
||||
|
||||
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken);
|
||||
|
||||
public string GetAlgorithmForPurpose(string purpose)
|
||||
=> HashAlgorithms.Sha256;
|
||||
|
||||
public string GetHashPrefix(string purpose)
|
||||
=> "sha256:";
|
||||
|
||||
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> $"{GetHashPrefix(purpose)}{ComputeHashHexForPurpose(data, purpose)}";
|
||||
|
||||
private static HashAlgorithm CreateAlgorithm(string? algorithmId)
|
||||
{
|
||||
return algorithmId?.ToUpperInvariant() switch
|
||||
{
|
||||
null or "" or HashAlgorithms.Sha256 => SHA256.Create(),
|
||||
HashAlgorithms.Sha512 => SHA512.Create(),
|
||||
_ => throw new NotSupportedException($"Test crypto hash does not support algorithm {algorithmId}.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
|
||||
using StellaOps.Excititor.Connectors.Abstractions.Trust;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
@@ -286,6 +287,10 @@ public sealed class RancherHubConnector : VexConnectorBase
|
||||
builder
|
||||
.Add("vex.provenance.trust.tier", tier)
|
||||
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
|
||||
|
||||
// Enrich with connector signer metadata (fingerprints, issuer tier, bundle info)
|
||||
// from external signer metadata file (STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH)
|
||||
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, Logger);
|
||||
}
|
||||
|
||||
private static bool TrimHistory(List<string> digestHistory)
|
||||
|
||||
@@ -461,7 +461,7 @@ public sealed class UbuntuCsafConnector : VexConnectorBase
|
||||
.Add("vex.provenance.trust.tier", tier)
|
||||
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
|
||||
|
||||
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
|
||||
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, Logger);
|
||||
}
|
||||
|
||||
private static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Attestation;
|
||||
|
||||
public class AttestationPointerServiceTests
|
||||
{
|
||||
private readonly Mock<ILedgerEventRepository> _ledgerEventRepository;
|
||||
private readonly Mock<ILedgerEventWriteService> _writeService;
|
||||
private readonly InMemoryAttestationPointerRepository _repository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly AttestationPointerService _service;
|
||||
|
||||
public AttestationPointerServiceTests()
|
||||
{
|
||||
_ledgerEventRepository = new Mock<ILedgerEventRepository>();
|
||||
_writeService = new Mock<ILedgerEventWriteService>();
|
||||
_repository = new InMemoryAttestationPointerRepository();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_writeService.Setup(w => w.AppendAsync(It.IsAny<LedgerEventDraft>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((LedgerEventDraft draft, CancellationToken _) =>
|
||||
{
|
||||
var record = new LedgerEventRecord(
|
||||
draft.TenantId,
|
||||
draft.ChainId,
|
||||
draft.SequenceNumber,
|
||||
draft.EventId,
|
||||
draft.EventType,
|
||||
draft.PolicyVersion,
|
||||
draft.FindingId,
|
||||
draft.ArtifactId,
|
||||
draft.SourceRunId,
|
||||
draft.ActorId,
|
||||
draft.ActorType,
|
||||
draft.OccurredAt,
|
||||
draft.RecordedAt,
|
||||
draft.Payload,
|
||||
"event-hash",
|
||||
draft.ProvidedPreviousHash ?? LedgerEventConstants.EmptyHash,
|
||||
"merkle-leaf-hash",
|
||||
draft.CanonicalEnvelope.ToJsonString());
|
||||
return LedgerWriteResult.Success(record);
|
||||
});
|
||||
|
||||
_service = new AttestationPointerService(
|
||||
_ledgerEventRepository.Object,
|
||||
_writeService.Object,
|
||||
_repository,
|
||||
_timeProvider,
|
||||
NullLogger<AttestationPointerService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePointer_CreatesNewPointer()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-123",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd",
|
||||
AttestationId: Guid.NewGuid(),
|
||||
StorageUri: "s3://attestations/test.json",
|
||||
PayloadType: "application/vnd.in-toto+json",
|
||||
PredicateType: "https://slsa.dev/provenance/v1"),
|
||||
VerificationResult: new VerificationResult(
|
||||
Verified: true,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Verifier: "stellaops-attestor",
|
||||
VerifierVersion: "2025.01.0"),
|
||||
CreatedBy: "test-system");
|
||||
|
||||
var result = await _service.CreatePointerAsync(input);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.PointerId);
|
||||
Assert.NotNull(result.LedgerEventId);
|
||||
Assert.Null(result.Error);
|
||||
|
||||
var saved = await _repository.GetByIdAsync("tenant-1", result.PointerId!.Value, CancellationToken.None);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(input.FindingId, saved!.FindingId);
|
||||
Assert.Equal(input.AttestationType, saved.AttestationType);
|
||||
Assert.Equal(input.AttestationRef.Digest, saved.AttestationRef.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePointer_IsIdempotent()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-456",
|
||||
AttestationType: AttestationType.SlsaProvenance,
|
||||
Relationship: AttestationRelationship.AttestedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:def456789012345678901234567890123456789012345678901234567890abcd"),
|
||||
CreatedBy: "test-system");
|
||||
|
||||
var result1 = await _service.CreatePointerAsync(input);
|
||||
var result2 = await _service.CreatePointerAsync(input);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.PointerId, result2.PointerId);
|
||||
|
||||
var pointers = await _repository.GetByFindingIdAsync("tenant-1", "finding-456", CancellationToken.None);
|
||||
Assert.Single(pointers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPointers_ReturnsAllPointersForFinding()
|
||||
{
|
||||
var input1 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-multi",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:aaa111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var input2 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-multi",
|
||||
AttestationType: AttestationType.VexAttestation,
|
||||
Relationship: AttestationRelationship.DerivedFrom,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:bbb111222333444555666777888999000111222333444555666777888999000b"));
|
||||
|
||||
await _service.CreatePointerAsync(input1);
|
||||
await _service.CreatePointerAsync(input2);
|
||||
|
||||
var pointers = await _service.GetPointersAsync("tenant-1", "finding-multi");
|
||||
|
||||
Assert.Equal(2, pointers.Count);
|
||||
Assert.Contains(pointers, p => p.AttestationType == AttestationType.DsseEnvelope);
|
||||
Assert.Contains(pointers, p => p.AttestationType == AttestationType.VexAttestation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSummary_CalculatesCorrectCounts()
|
||||
{
|
||||
var verified = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-summary",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:ver111222333444555666777888999000111222333444555666777888999000a"),
|
||||
VerificationResult: new VerificationResult(Verified: true, VerifiedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
var unverified = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-summary",
|
||||
AttestationType: AttestationType.SbomAttestation,
|
||||
Relationship: AttestationRelationship.DerivedFrom,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:unv111222333444555666777888999000111222333444555666777888999000b"));
|
||||
|
||||
await _service.CreatePointerAsync(verified);
|
||||
await _service.CreatePointerAsync(unverified);
|
||||
|
||||
var summary = await _service.GetSummaryAsync("tenant-1", "finding-summary");
|
||||
|
||||
Assert.Equal("finding-summary", summary.FindingId);
|
||||
Assert.Equal(2, summary.AttestationCount);
|
||||
Assert.Equal(1, summary.VerifiedCount);
|
||||
Assert.Equal(OverallVerificationStatus.PartiallyVerified, summary.OverallVerificationStatus);
|
||||
Assert.Contains(AttestationType.DsseEnvelope, summary.AttestationTypes);
|
||||
Assert.Contains(AttestationType.SbomAttestation, summary.AttestationTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_FiltersByAttestationType()
|
||||
{
|
||||
var input1 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-search-1",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:sea111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var input2 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-search-2",
|
||||
AttestationType: AttestationType.SlsaProvenance,
|
||||
Relationship: AttestationRelationship.AttestedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:sea222333444555666777888999000111222333444555666777888999000111b"));
|
||||
|
||||
await _service.CreatePointerAsync(input1);
|
||||
await _service.CreatePointerAsync(input2);
|
||||
|
||||
var query = new AttestationPointerQuery(
|
||||
TenantId: "tenant-1",
|
||||
AttestationTypes: new[] { AttestationType.DsseEnvelope });
|
||||
|
||||
var results = await _service.SearchAsync(query);
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(AttestationType.DsseEnvelope, results[0].AttestationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVerificationResult_UpdatesExistingPointer()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-update",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:upd111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var createResult = await _service.CreatePointerAsync(input);
|
||||
Assert.True(createResult.Success);
|
||||
|
||||
var verificationResult = new VerificationResult(
|
||||
Verified: true,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Verifier: "external-verifier",
|
||||
VerifierVersion: "1.0.0",
|
||||
Checks: new[]
|
||||
{
|
||||
new VerificationCheck(VerificationCheckType.SignatureValid, true, "ECDSA verified"),
|
||||
new VerificationCheck(VerificationCheckType.CertificateValid, true, "Chain verified")
|
||||
});
|
||||
|
||||
var success = await _service.UpdateVerificationResultAsync(
|
||||
"tenant-1", createResult.PointerId!.Value, verificationResult);
|
||||
|
||||
Assert.True(success);
|
||||
|
||||
var updated = await _repository.GetByIdAsync("tenant-1", createResult.PointerId!.Value, CancellationToken.None);
|
||||
Assert.NotNull(updated?.VerificationResult);
|
||||
Assert.True(updated.VerificationResult!.Verified);
|
||||
Assert.Equal("external-verifier", updated.VerificationResult.Verifier);
|
||||
Assert.Equal(2, updated.VerificationResult.Checks!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantIsolation_PreventsAccessAcrossTenants()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-a",
|
||||
FindingId: "finding-isolated",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:iso111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var result = await _service.CreatePointerAsync(input);
|
||||
Assert.True(result.Success);
|
||||
|
||||
var fromTenantA = await _service.GetPointersAsync("tenant-a", "finding-isolated");
|
||||
var fromTenantB = await _service.GetPointersAsync("tenant-b", "finding-isolated");
|
||||
|
||||
Assert.Single(fromTenantA);
|
||||
Assert.Empty(fromTenantB);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryAttestationPointerRepository : IAttestationPointerRepository
|
||||
{
|
||||
private readonly List<AttestationPointerRecord> _records = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_records.Add(record);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestationPointerRecord?> GetByIdAsync(string tenantId, Guid pointerId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var result = _records.FirstOrDefault(r => r.TenantId == tenantId && r.PointerId == pointerId);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records
|
||||
.Where(r => r.TenantId == tenantId && r.FindingId == findingId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(string tenantId, string digest, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records
|
||||
.Where(r => r.TenantId == tenantId && r.AttestationRef.Digest == digest)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(AttestationPointerQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records.Where(r => r.TenantId == query.TenantId);
|
||||
|
||||
if (query.FindingIds is { Count: > 0 })
|
||||
{
|
||||
results = results.Where(r => query.FindingIds.Contains(r.FindingId));
|
||||
}
|
||||
|
||||
if (query.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
results = results.Where(r => query.AttestationTypes.Contains(r.AttestationType));
|
||||
}
|
||||
|
||||
if (query.VerificationStatus.HasValue)
|
||||
{
|
||||
results = query.VerificationStatus.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
results.Where(r => r.VerificationResult?.Verified == true),
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
results.Where(r => r.VerificationResult is null),
|
||||
AttestationVerificationFilter.Failed =>
|
||||
results.Where(r => r.VerificationResult?.Verified == false),
|
||||
_ => results
|
||||
};
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.CreatedAt >= query.CreatedAfter.Value);
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.CreatedAt <= query.CreatedBefore.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.SignerIdentity))
|
||||
{
|
||||
results = results.Where(r => r.AttestationRef.SignerInfo?.Subject == query.SignerIdentity);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PredicateType))
|
||||
{
|
||||
results = results.Where(r => r.AttestationRef.PredicateType == query.PredicateType);
|
||||
}
|
||||
|
||||
var list = results
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<FindingAttestationSummary> GetSummaryAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var pointers = _records.Where(r => r.TenantId == tenantId && r.FindingId == findingId).ToList();
|
||||
|
||||
if (pointers.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new FindingAttestationSummary(
|
||||
findingId, 0, 0, null, Array.Empty<AttestationType>(), OverallVerificationStatus.NoAttestations));
|
||||
}
|
||||
|
||||
var verifiedCount = pointers.Count(p => p.VerificationResult?.Verified == true);
|
||||
var latest = pointers.Max(p => p.CreatedAt);
|
||||
var types = pointers.Select(p => p.AttestationType).Distinct().ToList();
|
||||
|
||||
var status = pointers.Count switch
|
||||
{
|
||||
0 => OverallVerificationStatus.NoAttestations,
|
||||
_ when verifiedCount == pointers.Count => OverallVerificationStatus.AllVerified,
|
||||
_ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
|
||||
_ => OverallVerificationStatus.NoneVerified
|
||||
};
|
||||
|
||||
return Task.FromResult(new FindingAttestationSummary(
|
||||
findingId, pointers.Count, verifiedCount, latest, types, status));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(string tenantId, IReadOnlyList<string> findingIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = findingIds.Select(fid => GetSummaryAsync(tenantId, fid, cancellationToken));
|
||||
return Task.WhenAll(tasks).ContinueWith(t => (IReadOnlyList<FindingAttestationSummary>)t.Result.ToList());
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string tenantId, string findingId, string digest, AttestationType attestationType, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var exists = _records.Any(r =>
|
||||
r.TenantId == tenantId &&
|
||||
r.FindingId == findingId &&
|
||||
r.AttestationRef.Digest == digest &&
|
||||
r.AttestationType == attestationType);
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateVerificationResultAsync(string tenantId, Guid pointerId, VerificationResult verificationResult, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var idx = _records.FindIndex(r => r.TenantId == tenantId && r.PointerId == pointerId);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var old = _records[idx];
|
||||
_records[idx] = old with { VerificationResult = verificationResult };
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> GetCountAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = _records.Count(r => r.TenantId == tenantId && r.FindingId == findingId);
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(string tenantId, AttestationVerificationFilter? verificationFilter, IReadOnlyList<AttestationType>? attestationTypes, int limit, int offset, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records.Where(r => r.TenantId == tenantId);
|
||||
|
||||
if (attestationTypes is { Count: > 0 })
|
||||
{
|
||||
results = results.Where(r => attestationTypes.Contains(r.AttestationType));
|
||||
}
|
||||
|
||||
if (verificationFilter.HasValue)
|
||||
{
|
||||
results = verificationFilter.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
results.Where(r => r.VerificationResult?.Verified == true),
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
results.Where(r => r.VerificationResult is null),
|
||||
AttestationVerificationFilter.Failed =>
|
||||
results.Where(r => r.VerificationResult?.Verified == false),
|
||||
_ => results
|
||||
};
|
||||
}
|
||||
|
||||
var list = results
|
||||
.Select(r => r.FindingId)
|
||||
.Distinct()
|
||||
.OrderBy(f => f)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
namespace StellaOps.Findings.Ledger.Tests.Snapshot;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using Xunit;
|
||||
|
||||
public class SnapshotServiceTests
|
||||
{
|
||||
private readonly InMemorySnapshotRepository _snapshotRepository;
|
||||
private readonly InMemoryTimeTravelRepository _timeTravelRepository;
|
||||
private readonly SnapshotService _service;
|
||||
|
||||
public SnapshotServiceTests()
|
||||
{
|
||||
_snapshotRepository = new InMemorySnapshotRepository();
|
||||
_timeTravelRepository = new InMemoryTimeTravelRepository();
|
||||
_service = new SnapshotService(
|
||||
_snapshotRepository,
|
||||
_timeTravelRepository,
|
||||
NullLogger<SnapshotService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_CreatesSnapshotSuccessfully()
|
||||
{
|
||||
var input = new CreateSnapshotInput(
|
||||
TenantId: "tenant-1",
|
||||
Label: "test-snapshot",
|
||||
Description: "Test description");
|
||||
|
||||
var result = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal("test-snapshot", result.Snapshot.Label);
|
||||
Assert.Equal(SnapshotStatus.Available, result.Snapshot.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_WithExpiry_SetsExpiresAt()
|
||||
{
|
||||
var input = new CreateSnapshotInput(
|
||||
TenantId: "tenant-1",
|
||||
Label: "expiring-snapshot",
|
||||
ExpiresIn: TimeSpan.FromHours(24));
|
||||
|
||||
var result = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Snapshot?.ExpiresAt);
|
||||
Assert.True(result.Snapshot.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshotAsync_ReturnsExistingSnapshot()
|
||||
{
|
||||
var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "get-test");
|
||||
var createResult = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-1", createResult.Snapshot!.SnapshotId);
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("get-test", snapshot.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshotAsync_ReturnsNullForNonExistent()
|
||||
{
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-1", Guid.NewGuid());
|
||||
|
||||
Assert.Null(snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListSnapshotsAsync_ReturnsAllSnapshots()
|
||||
{
|
||||
await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-1", Label: "snap-1"));
|
||||
await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-1", Label: "snap-2"));
|
||||
await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-2", Label: "snap-3"));
|
||||
|
||||
var (snapshots, _) = await _service.ListSnapshotsAsync(new SnapshotListQuery("tenant-1"));
|
||||
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSnapshotAsync_MarksAsDeleted()
|
||||
{
|
||||
var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "to-delete");
|
||||
var createResult = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
var deleted = await _service.DeleteSnapshotAsync("tenant-1", createResult.Snapshot!.SnapshotId);
|
||||
|
||||
Assert.True(deleted);
|
||||
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-1", createResult.Snapshot.SnapshotId);
|
||||
Assert.Equal(SnapshotStatus.Deleted, snapshot?.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentPointAsync_ReturnsLatestPoint()
|
||||
{
|
||||
var point = await _service.GetCurrentPointAsync("tenant-1");
|
||||
|
||||
Assert.True(point.SequenceNumber >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoricalFindingsAsync_ReturnsItems()
|
||||
{
|
||||
_timeTravelRepository.AddFinding("tenant-1", new FindingHistoryItem(
|
||||
"finding-1", "artifact-1", "CVE-2024-001", "open", 7.5m, "v1",
|
||||
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow, null));
|
||||
|
||||
var request = new HistoricalQueryRequest(
|
||||
"tenant-1", null, null, null, EntityType.Finding, null);
|
||||
|
||||
var result = await _service.QueryHistoricalFindingsAsync(request);
|
||||
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal("finding-1", result.Items[0].FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckStalenessAsync_ReturnsResult()
|
||||
{
|
||||
var result = await _service.CheckStalenessAsync("tenant-1", TimeSpan.FromHours(1));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.CheckedAt <= DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantIsolation_CannotAccessOtherTenantSnapshots()
|
||||
{
|
||||
var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "isolated");
|
||||
var createResult = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-2", createResult.Snapshot!.SnapshotId);
|
||||
|
||||
Assert.Null(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation for testing.
|
||||
/// </summary>
|
||||
internal class InMemorySnapshotRepository : ISnapshotRepository
|
||||
{
|
||||
private readonly List<LedgerSnapshot> _snapshots = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task<LedgerSnapshot> CreateAsync(
|
||||
string tenantId,
|
||||
CreateSnapshotInput input,
|
||||
long currentSequence,
|
||||
DateTimeOffset currentTimestamp,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var snapshot = new LedgerSnapshot(
|
||||
tenantId,
|
||||
Guid.NewGuid(),
|
||||
input.Label,
|
||||
input.Description,
|
||||
SnapshotStatus.Creating,
|
||||
DateTimeOffset.UtcNow,
|
||||
input.ExpiresIn.HasValue ? DateTimeOffset.UtcNow.Add(input.ExpiresIn.Value) : null,
|
||||
input.AtSequence ?? currentSequence,
|
||||
input.AtTimestamp ?? currentTimestamp,
|
||||
new SnapshotStatistics(0, 0, 0, 0, 0, 0),
|
||||
null,
|
||||
null,
|
||||
input.Metadata);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_snapshots.Add(snapshot);
|
||||
}
|
||||
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
|
||||
public Task<LedgerSnapshot?> GetByIdAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var snapshot = _snapshots.FirstOrDefault(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var filtered = _snapshots
|
||||
.Where(s => s.TenantId == query.TenantId)
|
||||
.Where(s => !query.Status.HasValue || s.Status == query.Status.Value)
|
||||
.Take(query.PageSize)
|
||||
.ToList();
|
||||
return Task.FromResult<(IReadOnlyList<LedgerSnapshot>, string?)>((filtered, null));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> UpdateStatusAsync(string tenantId, Guid snapshotId, SnapshotStatus newStatus, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
if (index < 0) return Task.FromResult(false);
|
||||
|
||||
_snapshots[index] = _snapshots[index] with { Status = newStatus };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> UpdateStatisticsAsync(string tenantId, Guid snapshotId, SnapshotStatistics statistics, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
if (index < 0) return Task.FromResult(false);
|
||||
|
||||
_snapshots[index] = _snapshots[index] with { Statistics = statistics };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> SetMerkleRootAsync(string tenantId, Guid snapshotId, string merkleRoot, string? dsseDigest, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
if (index < 0) return Task.FromResult(false);
|
||||
|
||||
_snapshots[index] = _snapshots[index] with { MerkleRoot = merkleRoot, DsseDigest = dsseDigest };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> ExpireSnapshotsAsync(DateTimeOffset cutoff, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = 0;
|
||||
for (int i = 0; i < _snapshots.Count; i++)
|
||||
{
|
||||
if (_snapshots[i].ExpiresAt.HasValue &&
|
||||
_snapshots[i].ExpiresAt < cutoff &&
|
||||
_snapshots[i].Status == SnapshotStatus.Available)
|
||||
{
|
||||
_snapshots[i] = _snapshots[i] with { Status = SnapshotStatus.Expired };
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
return UpdateStatusAsync(tenantId, snapshotId, SnapshotStatus.Deleted, ct);
|
||||
}
|
||||
|
||||
public Task<LedgerSnapshot?> GetLatestAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var snapshot = _snapshots
|
||||
.Where(s => s.TenantId == tenantId && s.Status == SnapshotStatus.Available)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_snapshots.Any(s => s.TenantId == tenantId && s.SnapshotId == snapshotId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory time-travel repository for testing.
|
||||
/// </summary>
|
||||
internal class InMemoryTimeTravelRepository : ITimeTravelRepository
|
||||
{
|
||||
private readonly Dictionary<string, List<FindingHistoryItem>> _findings = new();
|
||||
private readonly Dictionary<string, List<VexHistoryItem>> _vex = new();
|
||||
private readonly Dictionary<string, List<AdvisoryHistoryItem>> _advisories = new();
|
||||
private readonly Dictionary<string, List<ReplayEvent>> _events = new();
|
||||
private long _currentSequence = 100;
|
||||
|
||||
public void AddFinding(string tenantId, FindingHistoryItem finding)
|
||||
{
|
||||
if (!_findings.ContainsKey(tenantId))
|
||||
_findings[tenantId] = new List<FindingHistoryItem>();
|
||||
_findings[tenantId].Add(finding);
|
||||
}
|
||||
|
||||
public Task<QueryPoint> GetCurrentPointAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new QueryPoint(DateTimeOffset.UtcNow, _currentSequence));
|
||||
}
|
||||
|
||||
public Task<QueryPoint?> ResolveQueryPointAsync(string tenantId, DateTimeOffset? timestamp, long? sequence, Guid? snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<QueryPoint?>(new QueryPoint(timestamp ?? DateTimeOffset.UtcNow, sequence ?? _currentSequence, snapshotId));
|
||||
}
|
||||
|
||||
public Task<HistoricalQueryResponse<FindingHistoryItem>> QueryFindingsAsync(HistoricalQueryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _findings.TryGetValue(request.TenantId, out var list) ? list : new List<FindingHistoryItem>();
|
||||
var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
|
||||
return Task.FromResult(new HistoricalQueryResponse<FindingHistoryItem>(queryPoint, EntityType.Finding, items, null, items.Count));
|
||||
}
|
||||
|
||||
public Task<HistoricalQueryResponse<VexHistoryItem>> QueryVexAsync(HistoricalQueryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _vex.TryGetValue(request.TenantId, out var list) ? list : new List<VexHistoryItem>();
|
||||
var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
|
||||
return Task.FromResult(new HistoricalQueryResponse<VexHistoryItem>(queryPoint, EntityType.Vex, items, null, items.Count));
|
||||
}
|
||||
|
||||
public Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryAdvisoriesAsync(HistoricalQueryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _advisories.TryGetValue(request.TenantId, out var list) ? list : new List<AdvisoryHistoryItem>();
|
||||
var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
|
||||
return Task.FromResult(new HistoricalQueryResponse<AdvisoryHistoryItem>(queryPoint, EntityType.Advisory, items, null, items.Count));
|
||||
}
|
||||
|
||||
public Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(ReplayRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _events.TryGetValue(request.TenantId, out var list) ? list : new List<ReplayEvent>();
|
||||
var metadata = new ReplayMetadata(0, _currentSequence, items.Count, false, 10);
|
||||
return Task.FromResult<(IReadOnlyList<ReplayEvent>, ReplayMetadata)>((items, metadata));
|
||||
}
|
||||
|
||||
public Task<DiffResponse> ComputeDiffAsync(DiffRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var fromPoint = new QueryPoint(request.From.Timestamp ?? DateTimeOffset.UtcNow.AddHours(-1), request.From.SequenceNumber ?? 0);
|
||||
var toPoint = new QueryPoint(request.To.Timestamp ?? DateTimeOffset.UtcNow, request.To.SequenceNumber ?? _currentSequence);
|
||||
var summary = new DiffSummary(0, 0, 0, 0);
|
||||
return Task.FromResult(new DiffResponse(fromPoint, toPoint, summary, null, null));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(string tenantId, EntityType entityType, string entityId, int limit = 100, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ChangeLogEntry>>(new List<ChangeLogEntry>());
|
||||
}
|
||||
|
||||
public Task<StalenessResult> CheckStalenessAsync(string tenantId, TimeSpan threshold, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new StalenessResult(
|
||||
false,
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
threshold,
|
||||
TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationPointerRequest(
|
||||
string FindingId,
|
||||
string AttestationType,
|
||||
string Relationship,
|
||||
AttestationRefDto AttestationRef,
|
||||
VerificationResultDto? VerificationResult = null,
|
||||
string? CreatedBy = null,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an attestation artifact.
|
||||
/// </summary>
|
||||
public sealed record AttestationRefDto(
|
||||
string Digest,
|
||||
string? AttestationId = null,
|
||||
string? StorageUri = null,
|
||||
string? PayloadType = null,
|
||||
string? PredicateType = null,
|
||||
IReadOnlyList<string>? SubjectDigests = null,
|
||||
SignerInfoDto? SignerInfo = null,
|
||||
RekorEntryRefDto? RekorEntry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Information about the attestation signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfoDto(
|
||||
string? KeyId = null,
|
||||
string? Issuer = null,
|
||||
string? Subject = null,
|
||||
IReadOnlyList<string>? CertificateChain = null,
|
||||
DateTimeOffset? SignedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record RekorEntryRefDto(
|
||||
long? LogIndex = null,
|
||||
string? LogId = null,
|
||||
string? Uuid = null,
|
||||
long? IntegratedTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationResultDto(
|
||||
bool Verified,
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? Verifier = null,
|
||||
string? VerifierVersion = null,
|
||||
string? PolicyRef = null,
|
||||
IReadOnlyList<VerificationCheckDto>? Checks = null,
|
||||
IReadOnlyList<string>? Warnings = null,
|
||||
IReadOnlyList<string>? Errors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheckDto(
|
||||
string CheckType,
|
||||
bool Passed,
|
||||
string? Details = null,
|
||||
Dictionary<string, object>? Evidence = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response for creating an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationPointerResponse(
|
||||
bool Success,
|
||||
string? PointerId,
|
||||
string? LedgerEventId,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Response for getting attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerResponse(
|
||||
string PointerId,
|
||||
string FindingId,
|
||||
string AttestationType,
|
||||
string Relationship,
|
||||
AttestationRefDto AttestationRef,
|
||||
VerificationResultDto? VerificationResult,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedBy,
|
||||
Dictionary<string, object>? Metadata,
|
||||
string? LedgerEventId);
|
||||
|
||||
/// <summary>
|
||||
/// Response for attestation summary.
|
||||
/// </summary>
|
||||
public sealed record AttestationSummaryResponse(
|
||||
string FindingId,
|
||||
int AttestationCount,
|
||||
int VerifiedCount,
|
||||
DateTimeOffset? LatestAttestation,
|
||||
IReadOnlyList<string> AttestationTypes,
|
||||
string OverallVerificationStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerSearchRequest(
|
||||
IReadOnlyList<string>? FindingIds = null,
|
||||
IReadOnlyList<string>? AttestationTypes = null,
|
||||
string? VerificationStatus = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
string? SignerIdentity = null,
|
||||
string? PredicateType = null,
|
||||
int Limit = 100,
|
||||
int Offset = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Response for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerSearchResponse(
|
||||
IReadOnlyList<AttestationPointerResponse> Pointers,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update verification result.
|
||||
/// </summary>
|
||||
public sealed record UpdateVerificationResultRequest(
|
||||
VerificationResultDto VerificationResult);
|
||||
|
||||
/// <summary>
|
||||
/// Mapping extensions for attestation pointer DTOs.
|
||||
/// </summary>
|
||||
public static class AttestationPointerMappings
|
||||
{
|
||||
public static AttestationPointerInput ToInput(this CreateAttestationPointerRequest request, string tenantId)
|
||||
{
|
||||
if (!Enum.TryParse<AttestationType>(request.AttestationType, ignoreCase: true, out var attestationType))
|
||||
{
|
||||
throw new ArgumentException($"Invalid attestation type: {request.AttestationType}");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<AttestationRelationship>(request.Relationship, ignoreCase: true, out var relationship))
|
||||
{
|
||||
throw new ArgumentException($"Invalid relationship: {request.Relationship}");
|
||||
}
|
||||
|
||||
return new AttestationPointerInput(
|
||||
tenantId,
|
||||
request.FindingId,
|
||||
attestationType,
|
||||
relationship,
|
||||
request.AttestationRef.ToModel(),
|
||||
request.VerificationResult?.ToModel(),
|
||||
request.CreatedBy,
|
||||
request.Metadata);
|
||||
}
|
||||
|
||||
public static AttestationRef ToModel(this AttestationRefDto dto)
|
||||
{
|
||||
return new AttestationRef(
|
||||
dto.Digest,
|
||||
dto.AttestationId is not null ? Guid.Parse(dto.AttestationId) : null,
|
||||
dto.StorageUri,
|
||||
dto.PayloadType,
|
||||
dto.PredicateType,
|
||||
dto.SubjectDigests,
|
||||
dto.SignerInfo?.ToModel(),
|
||||
dto.RekorEntry?.ToModel());
|
||||
}
|
||||
|
||||
public static SignerInfo ToModel(this SignerInfoDto dto)
|
||||
{
|
||||
return new SignerInfo(
|
||||
dto.KeyId,
|
||||
dto.Issuer,
|
||||
dto.Subject,
|
||||
dto.CertificateChain,
|
||||
dto.SignedAt);
|
||||
}
|
||||
|
||||
public static RekorEntryRef ToModel(this RekorEntryRefDto dto)
|
||||
{
|
||||
return new RekorEntryRef(
|
||||
dto.LogIndex,
|
||||
dto.LogId,
|
||||
dto.Uuid,
|
||||
dto.IntegratedTime);
|
||||
}
|
||||
|
||||
public static VerificationResult ToModel(this VerificationResultDto dto)
|
||||
{
|
||||
return new VerificationResult(
|
||||
dto.Verified,
|
||||
dto.VerifiedAt,
|
||||
dto.Verifier,
|
||||
dto.VerifierVersion,
|
||||
dto.PolicyRef,
|
||||
dto.Checks?.Select(c => c.ToModel()).ToList(),
|
||||
dto.Warnings,
|
||||
dto.Errors);
|
||||
}
|
||||
|
||||
public static VerificationCheck ToModel(this VerificationCheckDto dto)
|
||||
{
|
||||
if (!Enum.TryParse<VerificationCheckType>(dto.CheckType, ignoreCase: true, out var checkType))
|
||||
{
|
||||
throw new ArgumentException($"Invalid check type: {dto.CheckType}");
|
||||
}
|
||||
|
||||
return new VerificationCheck(checkType, dto.Passed, dto.Details, dto.Evidence);
|
||||
}
|
||||
|
||||
public static AttestationPointerResponse ToResponse(this AttestationPointerRecord record)
|
||||
{
|
||||
return new AttestationPointerResponse(
|
||||
record.PointerId.ToString(),
|
||||
record.FindingId,
|
||||
record.AttestationType.ToString(),
|
||||
record.Relationship.ToString(),
|
||||
record.AttestationRef.ToDto(),
|
||||
record.VerificationResult?.ToDto(),
|
||||
record.CreatedAt,
|
||||
record.CreatedBy,
|
||||
record.Metadata,
|
||||
record.LedgerEventId?.ToString());
|
||||
}
|
||||
|
||||
public static AttestationRefDto ToDto(this AttestationRef model)
|
||||
{
|
||||
return new AttestationRefDto(
|
||||
model.Digest,
|
||||
model.AttestationId?.ToString(),
|
||||
model.StorageUri,
|
||||
model.PayloadType,
|
||||
model.PredicateType,
|
||||
model.SubjectDigests,
|
||||
model.SignerInfo?.ToDto(),
|
||||
model.RekorEntry?.ToDto());
|
||||
}
|
||||
|
||||
public static SignerInfoDto ToDto(this SignerInfo model)
|
||||
{
|
||||
return new SignerInfoDto(
|
||||
model.KeyId,
|
||||
model.Issuer,
|
||||
model.Subject,
|
||||
model.CertificateChain,
|
||||
model.SignedAt);
|
||||
}
|
||||
|
||||
public static RekorEntryRefDto ToDto(this RekorEntryRef model)
|
||||
{
|
||||
return new RekorEntryRefDto(
|
||||
model.LogIndex,
|
||||
model.LogId,
|
||||
model.Uuid,
|
||||
model.IntegratedTime);
|
||||
}
|
||||
|
||||
public static VerificationResultDto ToDto(this VerificationResult model)
|
||||
{
|
||||
return new VerificationResultDto(
|
||||
model.Verified,
|
||||
model.VerifiedAt,
|
||||
model.Verifier,
|
||||
model.VerifierVersion,
|
||||
model.PolicyRef,
|
||||
model.Checks?.Select(c => c.ToDto()).ToList(),
|
||||
model.Warnings,
|
||||
model.Errors);
|
||||
}
|
||||
|
||||
public static VerificationCheckDto ToDto(this VerificationCheck model)
|
||||
{
|
||||
return new VerificationCheckDto(
|
||||
model.CheckType.ToString(),
|
||||
model.Passed,
|
||||
model.Details,
|
||||
model.Evidence);
|
||||
}
|
||||
|
||||
public static AttestationSummaryResponse ToResponse(this FindingAttestationSummary summary)
|
||||
{
|
||||
return new AttestationSummaryResponse(
|
||||
summary.FindingId,
|
||||
summary.AttestationCount,
|
||||
summary.VerifiedCount,
|
||||
summary.LatestAttestation,
|
||||
summary.AttestationTypes.Select(t => t.ToString()).ToList(),
|
||||
summary.OverallVerificationStatus.ToString());
|
||||
}
|
||||
|
||||
public static AttestationPointerQuery ToQuery(this AttestationPointerSearchRequest request, string tenantId)
|
||||
{
|
||||
IReadOnlyList<AttestationType>? attestationTypes = null;
|
||||
if (request.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
attestationTypes = request.AttestationTypes
|
||||
.Where(t => Enum.TryParse<AttestationType>(t, ignoreCase: true, out _))
|
||||
.Select(t => Enum.Parse<AttestationType>(t, ignoreCase: true))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
AttestationVerificationFilter? verificationFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.VerificationStatus))
|
||||
{
|
||||
if (Enum.TryParse<AttestationVerificationFilter>(request.VerificationStatus, ignoreCase: true, out var filter))
|
||||
{
|
||||
verificationFilter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
return new AttestationPointerQuery(
|
||||
tenantId,
|
||||
request.FindingIds,
|
||||
attestationTypes,
|
||||
verificationFilter,
|
||||
request.CreatedAfter,
|
||||
request.CreatedBefore,
|
||||
request.SignerIdentity,
|
||||
request.PredicateType,
|
||||
request.Limit,
|
||||
request.Offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
// === Snapshot Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotRequest(
|
||||
string? Label = null,
|
||||
string? Description = null,
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
int? ExpiresInHours = null,
|
||||
IReadOnlyList<string>? IncludeEntityTypes = null,
|
||||
bool Sign = false,
|
||||
Dictionary<string, object>? Metadata = null)
|
||||
{
|
||||
public CreateSnapshotInput ToInput(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
Label: Label,
|
||||
Description: Description,
|
||||
AtTimestamp: AtTimestamp,
|
||||
AtSequence: AtSequence,
|
||||
ExpiresIn: ExpiresInHours.HasValue ? TimeSpan.FromHours(ExpiresInHours.Value) : null,
|
||||
IncludeEntityTypes: IncludeEntityTypes?.Select(ParseEntityType).ToList(),
|
||||
Sign: Sign,
|
||||
Metadata: Metadata);
|
||||
|
||||
private static EntityType ParseEntityType(string s) =>
|
||||
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotResponse(
|
||||
Guid SnapshotId,
|
||||
string? Label,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
SnapshotStatisticsResponse Statistics,
|
||||
string? MerkleRoot,
|
||||
string? DsseDigest,
|
||||
Dictionary<string, object>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Response for snapshot statistics.
|
||||
/// </summary>
|
||||
public sealed record SnapshotStatisticsResponse(
|
||||
long FindingsCount,
|
||||
long VexStatementsCount,
|
||||
long AdvisoriesCount,
|
||||
long SbomsCount,
|
||||
long EventsCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotResponse(
|
||||
bool Success,
|
||||
SnapshotResponse? Snapshot,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing snapshots.
|
||||
/// </summary>
|
||||
public sealed record SnapshotListResponse(
|
||||
IReadOnlyList<SnapshotResponse> Snapshots,
|
||||
string? NextPageToken);
|
||||
|
||||
// === Time-Travel Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryApiRequest(
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
Guid? SnapshotId = null,
|
||||
string? Status = null,
|
||||
decimal? SeverityMin = null,
|
||||
decimal? SeverityMax = null,
|
||||
string? PolicyVersion = null,
|
||||
string? ArtifactId = null,
|
||||
string? VulnId = null,
|
||||
int PageSize = 500,
|
||||
string? PageToken = null)
|
||||
{
|
||||
public HistoricalQueryRequest ToRequest(string tenantId, EntityType entityType) => new(
|
||||
TenantId: tenantId,
|
||||
AtTimestamp: AtTimestamp,
|
||||
AtSequence: AtSequence,
|
||||
SnapshotId: SnapshotId,
|
||||
EntityType: entityType,
|
||||
Filters: new TimeQueryFilters(
|
||||
Status: Status,
|
||||
SeverityMin: SeverityMin,
|
||||
SeverityMax: SeverityMax,
|
||||
PolicyVersion: PolicyVersion,
|
||||
ArtifactId: ArtifactId,
|
||||
VulnId: VulnId),
|
||||
PageSize: PageSize,
|
||||
PageToken: PageToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryApiResponse<T>(
|
||||
QueryPointResponse QueryPoint,
|
||||
string EntityType,
|
||||
IReadOnlyList<T> Items,
|
||||
string? NextPageToken,
|
||||
long TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Query point response.
|
||||
/// </summary>
|
||||
public sealed record QueryPointResponse(
|
||||
DateTimeOffset Timestamp,
|
||||
long SequenceNumber,
|
||||
Guid? SnapshotId);
|
||||
|
||||
/// <summary>
|
||||
/// Finding history item response.
|
||||
/// </summary>
|
||||
public sealed record FindingHistoryResponse(
|
||||
string FindingId,
|
||||
string ArtifactId,
|
||||
string VulnId,
|
||||
string Status,
|
||||
decimal? Severity,
|
||||
string? PolicyVersion,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastUpdated,
|
||||
Dictionary<string, string>? Labels);
|
||||
|
||||
/// <summary>
|
||||
/// VEX history item response.
|
||||
/// </summary>
|
||||
public sealed record VexHistoryResponse(
|
||||
string StatementId,
|
||||
string VulnId,
|
||||
string ProductId,
|
||||
string Status,
|
||||
string? Justification,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Advisory history item response.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryHistoryResponse(
|
||||
string AdvisoryId,
|
||||
string Source,
|
||||
string Title,
|
||||
decimal? CvssScore,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset? ModifiedAt);
|
||||
|
||||
// === Replay Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for replaying events.
|
||||
/// </summary>
|
||||
public sealed record ReplayApiRequest(
|
||||
long? FromSequence = null,
|
||||
long? ToSequence = null,
|
||||
DateTimeOffset? FromTimestamp = null,
|
||||
DateTimeOffset? ToTimestamp = null,
|
||||
IReadOnlyList<Guid>? ChainIds = null,
|
||||
IReadOnlyList<string>? EventTypes = null,
|
||||
bool IncludePayload = true,
|
||||
int PageSize = 1000)
|
||||
{
|
||||
public ReplayRequest ToRequest(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
FromSequence: FromSequence,
|
||||
ToSequence: ToSequence,
|
||||
FromTimestamp: FromTimestamp,
|
||||
ToTimestamp: ToTimestamp,
|
||||
ChainIds: ChainIds,
|
||||
EventTypes: EventTypes,
|
||||
IncludePayload: IncludePayload,
|
||||
PageSize: PageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for replay.
|
||||
/// </summary>
|
||||
public sealed record ReplayApiResponse(
|
||||
IReadOnlyList<ReplayEventResponse> Events,
|
||||
ReplayMetadataResponse Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Replay event response.
|
||||
/// </summary>
|
||||
public sealed record ReplayEventResponse(
|
||||
Guid EventId,
|
||||
long SequenceNumber,
|
||||
Guid ChainId,
|
||||
int ChainSequence,
|
||||
string EventType,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? ActorId,
|
||||
string? ActorType,
|
||||
string? ArtifactId,
|
||||
string? FindingId,
|
||||
string? PolicyVersion,
|
||||
string EventHash,
|
||||
string PreviousHash,
|
||||
object? Payload);
|
||||
|
||||
/// <summary>
|
||||
/// Replay metadata response.
|
||||
/// </summary>
|
||||
public sealed record ReplayMetadataResponse(
|
||||
long FromSequence,
|
||||
long ToSequence,
|
||||
long EventsCount,
|
||||
bool HasMore,
|
||||
long ReplayDurationMs);
|
||||
|
||||
// === Diff Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for computing diff.
|
||||
/// </summary>
|
||||
public sealed record DiffApiRequest(
|
||||
DiffPointRequest From,
|
||||
DiffPointRequest To,
|
||||
IReadOnlyList<string>? EntityTypes = null,
|
||||
bool IncludeUnchanged = false,
|
||||
string OutputFormat = "Summary")
|
||||
{
|
||||
public DiffRequest ToRequest(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
From: From.ToDiffPoint(),
|
||||
To: To.ToDiffPoint(),
|
||||
EntityTypes: EntityTypes?.Select(ParseEntityType).ToList(),
|
||||
IncludeUnchanged: IncludeUnchanged,
|
||||
OutputFormat: Enum.TryParse<DiffOutputFormat>(OutputFormat, true, out var fmt)
|
||||
? fmt : DiffOutputFormat.Summary);
|
||||
|
||||
private static EntityType ParseEntityType(string s) =>
|
||||
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff point request.
|
||||
/// </summary>
|
||||
public sealed record DiffPointRequest(
|
||||
DateTimeOffset? Timestamp = null,
|
||||
long? SequenceNumber = null,
|
||||
Guid? SnapshotId = null)
|
||||
{
|
||||
public DiffPoint ToDiffPoint() => new(Timestamp, SequenceNumber, SnapshotId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for diff.
|
||||
/// </summary>
|
||||
public sealed record DiffApiResponse(
|
||||
QueryPointResponse FromPoint,
|
||||
QueryPointResponse ToPoint,
|
||||
DiffSummaryResponse Summary,
|
||||
IReadOnlyList<DiffEntryResponse>? Changes,
|
||||
string? NextPageToken);
|
||||
|
||||
/// <summary>
|
||||
/// Diff summary response.
|
||||
/// </summary>
|
||||
public sealed record DiffSummaryResponse(
|
||||
int Added,
|
||||
int Modified,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
Dictionary<string, DiffCountsResponse>? ByEntityType);
|
||||
|
||||
/// <summary>
|
||||
/// Diff counts response.
|
||||
/// </summary>
|
||||
public sealed record DiffCountsResponse(int Added, int Modified, int Removed);
|
||||
|
||||
/// <summary>
|
||||
/// Diff entry response.
|
||||
/// </summary>
|
||||
public sealed record DiffEntryResponse(
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string ChangeType,
|
||||
object? FromState,
|
||||
object? ToState,
|
||||
IReadOnlyList<string>? ChangedFields);
|
||||
|
||||
// === Changelog Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Changelog entry response.
|
||||
/// </summary>
|
||||
public sealed record ChangeLogEntryResponse(
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string EventType,
|
||||
string? EventHash,
|
||||
string? ActorId,
|
||||
string? Summary);
|
||||
|
||||
// === Staleness Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Staleness check response.
|
||||
/// </summary>
|
||||
public sealed record StalenessResponse(
|
||||
bool IsStale,
|
||||
DateTimeOffset CheckedAt,
|
||||
DateTimeOffset? LastEventAt,
|
||||
string StalenessThreshold,
|
||||
string? StalenessDuration,
|
||||
Dictionary<string, EntityStalenessResponse>? ByEntityType);
|
||||
|
||||
/// <summary>
|
||||
/// Entity staleness response.
|
||||
/// </summary>
|
||||
public sealed record EntityStalenessResponse(
|
||||
bool IsStale,
|
||||
DateTimeOffset? LastEventAt,
|
||||
long EventsBehind);
|
||||
|
||||
// === Extension Methods ===
|
||||
|
||||
public static class SnapshotExtensions
|
||||
{
|
||||
public static SnapshotResponse ToResponse(this LedgerSnapshot snapshot) => new(
|
||||
SnapshotId: snapshot.SnapshotId,
|
||||
Label: snapshot.Label,
|
||||
Description: snapshot.Description,
|
||||
Status: snapshot.Status.ToString(),
|
||||
CreatedAt: snapshot.CreatedAt,
|
||||
ExpiresAt: snapshot.ExpiresAt,
|
||||
SequenceNumber: snapshot.SequenceNumber,
|
||||
Timestamp: snapshot.Timestamp,
|
||||
Statistics: snapshot.Statistics.ToResponse(),
|
||||
MerkleRoot: snapshot.MerkleRoot,
|
||||
DsseDigest: snapshot.DsseDigest,
|
||||
Metadata: snapshot.Metadata);
|
||||
|
||||
public static SnapshotStatisticsResponse ToResponse(this SnapshotStatistics stats) => new(
|
||||
FindingsCount: stats.FindingsCount,
|
||||
VexStatementsCount: stats.VexStatementsCount,
|
||||
AdvisoriesCount: stats.AdvisoriesCount,
|
||||
SbomsCount: stats.SbomsCount,
|
||||
EventsCount: stats.EventsCount,
|
||||
SizeBytes: stats.SizeBytes);
|
||||
|
||||
public static QueryPointResponse ToResponse(this QueryPoint point) => new(
|
||||
Timestamp: point.Timestamp,
|
||||
SequenceNumber: point.SequenceNumber,
|
||||
SnapshotId: point.SnapshotId);
|
||||
|
||||
public static FindingHistoryResponse ToResponse(this FindingHistoryItem item) => new(
|
||||
FindingId: item.FindingId,
|
||||
ArtifactId: item.ArtifactId,
|
||||
VulnId: item.VulnId,
|
||||
Status: item.Status,
|
||||
Severity: item.Severity,
|
||||
PolicyVersion: item.PolicyVersion,
|
||||
FirstSeen: item.FirstSeen,
|
||||
LastUpdated: item.LastUpdated,
|
||||
Labels: item.Labels);
|
||||
|
||||
public static VexHistoryResponse ToResponse(this VexHistoryItem item) => new(
|
||||
StatementId: item.StatementId,
|
||||
VulnId: item.VulnId,
|
||||
ProductId: item.ProductId,
|
||||
Status: item.Status,
|
||||
Justification: item.Justification,
|
||||
IssuedAt: item.IssuedAt,
|
||||
ExpiresAt: item.ExpiresAt);
|
||||
|
||||
public static AdvisoryHistoryResponse ToResponse(this AdvisoryHistoryItem item) => new(
|
||||
AdvisoryId: item.AdvisoryId,
|
||||
Source: item.Source,
|
||||
Title: item.Title,
|
||||
CvssScore: item.CvssScore,
|
||||
PublishedAt: item.PublishedAt,
|
||||
ModifiedAt: item.ModifiedAt);
|
||||
|
||||
public static ReplayEventResponse ToResponse(this ReplayEvent e) => new(
|
||||
EventId: e.EventId,
|
||||
SequenceNumber: e.SequenceNumber,
|
||||
ChainId: e.ChainId,
|
||||
ChainSequence: e.ChainSequence,
|
||||
EventType: e.EventType,
|
||||
OccurredAt: e.OccurredAt,
|
||||
RecordedAt: e.RecordedAt,
|
||||
ActorId: e.ActorId,
|
||||
ActorType: e.ActorType,
|
||||
ArtifactId: e.ArtifactId,
|
||||
FindingId: e.FindingId,
|
||||
PolicyVersion: e.PolicyVersion,
|
||||
EventHash: e.EventHash,
|
||||
PreviousHash: e.PreviousHash,
|
||||
Payload: e.Payload);
|
||||
|
||||
public static ReplayMetadataResponse ToResponse(this ReplayMetadata m) => new(
|
||||
FromSequence: m.FromSequence,
|
||||
ToSequence: m.ToSequence,
|
||||
EventsCount: m.EventsCount,
|
||||
HasMore: m.HasMore,
|
||||
ReplayDurationMs: m.ReplayDurationMs);
|
||||
|
||||
public static DiffSummaryResponse ToResponse(this DiffSummary summary) => new(
|
||||
Added: summary.Added,
|
||||
Modified: summary.Modified,
|
||||
Removed: summary.Removed,
|
||||
Unchanged: summary.Unchanged,
|
||||
ByEntityType: summary.ByEntityType?.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => new DiffCountsResponse(kv.Value.Added, kv.Value.Modified, kv.Value.Removed)));
|
||||
|
||||
public static DiffEntryResponse ToResponse(this DiffEntry entry) => new(
|
||||
EntityType: entry.EntityType.ToString(),
|
||||
EntityId: entry.EntityId,
|
||||
ChangeType: entry.ChangeType.ToString(),
|
||||
FromState: entry.FromState,
|
||||
ToState: entry.ToState,
|
||||
ChangedFields: entry.ChangedFields);
|
||||
|
||||
public static ChangeLogEntryResponse ToResponse(this ChangeLogEntry entry) => new(
|
||||
SequenceNumber: entry.SequenceNumber,
|
||||
Timestamp: entry.Timestamp,
|
||||
EntityType: entry.EntityType.ToString(),
|
||||
EntityId: entry.EntityId,
|
||||
EventType: entry.EventType,
|
||||
EventHash: entry.EventHash,
|
||||
ActorId: entry.ActorId,
|
||||
Summary: entry.Summary);
|
||||
|
||||
public static StalenessResponse ToResponse(this StalenessResult result) => new(
|
||||
IsStale: result.IsStale,
|
||||
CheckedAt: result.CheckedAt,
|
||||
LastEventAt: result.LastEventAt,
|
||||
StalenessThreshold: result.StalenessThreshold.ToString(),
|
||||
StalenessDuration: result.StalenessDuration?.ToString(),
|
||||
ByEntityType: result.ByEntityType?.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => new EntityStalenessResponse(kv.Value.IsStale, kv.Value.LastEventAt, kv.Value.EventsBehind)));
|
||||
}
|
||||
@@ -155,6 +155,14 @@ builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
|
||||
builder.Services.AddHostedService<LedgerProjectionWorker>();
|
||||
builder.Services.AddSingleton<ExportQueryService>();
|
||||
builder.Services.AddSingleton<AttestationQueryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Attestation.IAttestationPointerRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresAttestationPointerRepository>();
|
||||
builder.Services.AddSingleton<AttestationPointerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ISnapshotRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresSnapshotRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ITimeTravelRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresTimeTravelRepository>();
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -633,6 +641,206 @@ app.MapPost("/internal/ledger/airgap-import", async Task<Results<Accepted<Airgap
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
// Attestation Pointer Endpoints (LEDGER-ATTEST-73-001)
|
||||
app.MapPost("/v1/ledger/attestation-pointers", async Task<Results<Created<CreateAttestationPointerResponse>, Ok<CreateAttestationPointerResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
CreateAttestationPointerRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var input = request.ToInput(tenantId);
|
||||
var result = await service.CreatePointerAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new CreateAttestationPointerResponse(
|
||||
result.Success,
|
||||
result.PointerId?.ToString(),
|
||||
result.LedgerEventId?.ToString(),
|
||||
result.Error);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "attestation_pointer_failed",
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/ledger/attestation-pointers/{result.PointerId}", response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("CreateAttestationPointer")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/attestation-pointers/{pointerId}", async Task<Results<JsonHttpResult<AttestationPointerResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string pointerId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_pointer_id",
|
||||
detail: "Pointer ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var pointer = await service.GetPointerAsync(tenantId, pointerGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (pointer is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Json(pointer.ToResponse());
|
||||
})
|
||||
.WithName("GetAttestationPointer")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/findings/{findingId}/attestation-pointers", async Task<Results<JsonHttpResult<IReadOnlyList<AttestationPointerResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string findingId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var pointers = await service.GetPointersAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
||||
IReadOnlyList<AttestationPointerResponse> responseList = pointers.Select(p => p.ToResponse()).ToList();
|
||||
return TypedResults.Json(responseList);
|
||||
})
|
||||
.WithName("GetFindingAttestationPointers")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/findings/{findingId}/attestation-summary", async Task<Results<JsonHttpResult<AttestationSummaryResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string findingId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
||||
return TypedResults.Json(summary.ToResponse());
|
||||
})
|
||||
.WithName("GetFindingAttestationSummary")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPost("/v1/ledger/attestation-pointers/search", async Task<Results<JsonHttpResult<AttestationPointerSearchResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
AttestationPointerSearchRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var query = request.ToQuery(tenantId);
|
||||
var pointers = await service.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new AttestationPointerSearchResponse(
|
||||
pointers.Select(p => p.ToResponse()).ToList(),
|
||||
pointers.Count);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("SearchAttestationPointers")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string pointerId,
|
||||
UpdateVerificationResultRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_pointer_id",
|
||||
detail: "Pointer ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verificationResult = request.VerificationResult.ToModel();
|
||||
var success = await service.UpdateVerificationResultAsync(tenantId, pointerGuid, verificationResult, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("UpdateAttestationPointerVerification")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/.well-known/openapi", () =>
|
||||
{
|
||||
var contentRoot = AppContext.BaseDirectory;
|
||||
@@ -649,6 +857,383 @@ app.MapGet("/.well-known/openapi", () =>
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)
|
||||
app.MapPost("/v1/ledger/snapshots", async Task<Results<Created<CreateSnapshotResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
CreateSnapshotRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var input = request.ToInput(tenantId);
|
||||
var result = await service.CreateSnapshotAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new CreateSnapshotResponse(
|
||||
result.Success,
|
||||
result.Snapshot?.ToResponse(),
|
||||
result.Error);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "snapshot_creation_failed",
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/ledger/snapshots/{result.Snapshot!.SnapshotId}", response);
|
||||
})
|
||||
.WithName("CreateSnapshot")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/snapshots", async Task<Results<JsonHttpResult<SnapshotListResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var statusStr = httpContext.Request.Query["status"].ToString();
|
||||
Domain.SnapshotStatus? status = null;
|
||||
if (!string.IsNullOrEmpty(statusStr) && Enum.TryParse<Domain.SnapshotStatus>(statusStr, true, out var parsedStatus))
|
||||
{
|
||||
status = parsedStatus;
|
||||
}
|
||||
|
||||
var query = new Domain.SnapshotListQuery(
|
||||
tenantId,
|
||||
status,
|
||||
ParseDate(httpContext.Request.Query["created_after"]),
|
||||
ParseDate(httpContext.Request.Query["created_before"]),
|
||||
ParseInt(httpContext.Request.Query["page_size"]) ?? 100,
|
||||
httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var (snapshots, nextPageToken) = await service.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new SnapshotListResponse(
|
||||
snapshots.Select(s => s.ToResponse()).ToList(),
|
||||
nextPageToken);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ListSnapshots")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/snapshots/{snapshotId}", async Task<Results<JsonHttpResult<SnapshotResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_snapshot_id",
|
||||
detail: "Snapshot ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var snapshot = await service.GetSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Json(snapshot.ToResponse());
|
||||
})
|
||||
.WithName("GetSnapshot")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapDelete("/v1/ledger/snapshots/{snapshotId}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_snapshot_id",
|
||||
detail: "Snapshot ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var deleted = await service.DeleteSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
})
|
||||
.WithName("DeleteSnapshot")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Time-Travel Query Endpoints
|
||||
app.MapGet("/v1/ledger/time-travel/findings", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<FindingHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
Status: httpContext.Request.Query["status"].ToString(),
|
||||
SeverityMin: ParseDecimal(httpContext.Request.Query["severity_min"]),
|
||||
SeverityMax: ParseDecimal(httpContext.Request.Query["severity_max"]),
|
||||
PolicyVersion: httpContext.Request.Query["policy_version"].ToString(),
|
||||
ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
|
||||
VulnId: httpContext.Request.Query["vuln_id"].ToString(),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Finding);
|
||||
var result = await service.QueryHistoricalFindingsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<FindingHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Finding",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryFindings")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/time-travel/vex", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<VexHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Vex);
|
||||
var result = await service.QueryHistoricalVexAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<VexHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Vex",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryVex")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/time-travel/advisories", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<AdvisoryHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Advisory);
|
||||
var result = await service.QueryHistoricalAdvisoriesAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<AdvisoryHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Advisory",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryAdvisories")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Replay Endpoint
|
||||
app.MapPost("/v1/ledger/replay", async Task<Results<JsonHttpResult<ReplayApiResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
ReplayApiRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId);
|
||||
var (events, metadata) = await service.ReplayEventsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new ReplayApiResponse(
|
||||
events.Select(e => e.ToResponse()).ToList(),
|
||||
metadata.ToResponse());
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ReplayEvents")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Diff Endpoint
|
||||
app.MapPost("/v1/ledger/diff", async Task<Results<JsonHttpResult<DiffApiResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
DiffApiRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId);
|
||||
var result = await service.ComputeDiffAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new DiffApiResponse(
|
||||
result.FromPoint.ToResponse(),
|
||||
result.ToPoint.ToResponse(),
|
||||
result.Summary.ToResponse(),
|
||||
result.Changes?.Select(c => c.ToResponse()).ToList(),
|
||||
result.NextPageToken);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ComputeDiff")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Changelog Endpoint
|
||||
app.MapGet("/v1/ledger/changelog/{entityType}/{entityId}", async Task<Results<JsonHttpResult<IReadOnlyList<ChangeLogEntryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string entityType,
|
||||
string entityId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<Domain.EntityType>(entityType, true, out var parsedEntityType))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_entity_type",
|
||||
detail: "Entity type must be one of: Finding, Vex, Advisory, Sbom, Evidence.");
|
||||
}
|
||||
|
||||
var limit = ParseInt(httpContext.Request.Query["limit"]) ?? 100;
|
||||
var changelog = await service.GetChangelogAsync(tenantId, parsedEntityType, entityId, limit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<ChangeLogEntryResponse> response = changelog.Select(e => e.ToResponse()).ToList();
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("GetChangelog")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Staleness Check Endpoint
|
||||
app.MapGet("/v1/ledger/staleness", async Task<Results<JsonHttpResult<StalenessResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var thresholdMinutes = ParseInt(httpContext.Request.Query["threshold_minutes"]) ?? 60;
|
||||
var threshold = TimeSpan.FromMinutes(thresholdMinutes);
|
||||
|
||||
var result = await service.CheckStalenessAsync(tenantId, threshold, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Json(result.ToResponse());
|
||||
})
|
||||
.WithName("CheckStaleness")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Current Point Endpoint
|
||||
app.MapGet("/v1/ledger/current-point", async Task<Results<JsonHttpResult<QueryPointResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var point = await service.GetCurrentPointAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return TypedResults.Json(point.ToResponse());
|
||||
})
|
||||
.WithName("GetCurrentPoint")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.Run();
|
||||
|
||||
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
|
||||
@@ -738,3 +1323,8 @@ static bool? ParseBool(string value)
|
||||
{
|
||||
return bool.TryParse(value, out var result) ? result : null;
|
||||
}
|
||||
|
||||
static Guid? ParseGuid(string value)
|
||||
{
|
||||
return Guid.TryParse(value, out var result) ? result : null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public static class LedgerEventConstants
|
||||
public const string EventEvidenceSnapshotLinked = "airgap.evidence_snapshot_linked";
|
||||
public const string EventAirgapTimelineImpact = "airgap.timeline_impact";
|
||||
public const string EventOrchestratorExportRecorded = "orchestrator.export_recorded";
|
||||
public const string EventAttestationPointerLinked = "attestation.pointer_linked";
|
||||
|
||||
public static readonly ImmutableHashSet<string> SupportedEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
|
||||
EventFindingCreated,
|
||||
@@ -33,7 +34,8 @@ public static class LedgerEventConstants
|
||||
EventAirgapBundleImported,
|
||||
EventEvidenceSnapshotLinked,
|
||||
EventAirgapTimelineImpact,
|
||||
EventOrchestratorExportRecorded);
|
||||
EventOrchestratorExportRecorded,
|
||||
EventAttestationPointerLinked);
|
||||
|
||||
public static readonly ImmutableHashSet<string> FindingEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
|
||||
EventFindingCreated,
|
||||
|
||||
281
src/Findings/StellaOps.Findings.Ledger/Domain/SnapshotModels.cs
Normal file
281
src/Findings/StellaOps.Findings.Ledger/Domain/SnapshotModels.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
namespace StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a point-in-time snapshot of ledger state.
|
||||
/// </summary>
|
||||
public sealed record LedgerSnapshot(
|
||||
string TenantId,
|
||||
Guid SnapshotId,
|
||||
string? Label,
|
||||
string? Description,
|
||||
SnapshotStatus Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
SnapshotStatistics Statistics,
|
||||
string? MerkleRoot,
|
||||
string? DsseDigest,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot lifecycle status.
|
||||
/// </summary>
|
||||
public enum SnapshotStatus
|
||||
{
|
||||
Creating,
|
||||
Available,
|
||||
Exporting,
|
||||
Expired,
|
||||
Deleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for a snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotStatistics(
|
||||
long FindingsCount,
|
||||
long VexStatementsCount,
|
||||
long AdvisoriesCount,
|
||||
long SbomsCount,
|
||||
long EventsCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotInput(
|
||||
string TenantId,
|
||||
string? Label = null,
|
||||
string? Description = null,
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
TimeSpan? ExpiresIn = null,
|
||||
IReadOnlyList<EntityType>? IncludeEntityTypes = null,
|
||||
bool Sign = false,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotResult(
|
||||
bool Success,
|
||||
LedgerSnapshot? Snapshot,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Entity types tracked in the ledger.
|
||||
/// </summary>
|
||||
public enum EntityType
|
||||
{
|
||||
Finding,
|
||||
Vex,
|
||||
Advisory,
|
||||
Sbom,
|
||||
Evidence
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query point specification (timestamp or sequence).
|
||||
/// </summary>
|
||||
public sealed record QueryPoint(
|
||||
DateTimeOffset Timestamp,
|
||||
long SequenceNumber,
|
||||
Guid? SnapshotId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Filters for time-travel queries.
|
||||
/// </summary>
|
||||
public sealed record TimeQueryFilters(
|
||||
string? Status = null,
|
||||
decimal? SeverityMin = null,
|
||||
decimal? SeverityMax = null,
|
||||
string? PolicyVersion = null,
|
||||
string? ArtifactId = null,
|
||||
string? VulnId = null,
|
||||
Dictionary<string, string>? Labels = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryRequest(
|
||||
string TenantId,
|
||||
DateTimeOffset? AtTimestamp,
|
||||
long? AtSequence,
|
||||
Guid? SnapshotId,
|
||||
EntityType EntityType,
|
||||
TimeQueryFilters? Filters,
|
||||
int PageSize = 500,
|
||||
string? PageToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryResponse<T>(
|
||||
QueryPoint QueryPoint,
|
||||
EntityType EntityType,
|
||||
IReadOnlyList<T> Items,
|
||||
string? NextPageToken,
|
||||
long TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Request for replaying events.
|
||||
/// </summary>
|
||||
public sealed record ReplayRequest(
|
||||
string TenantId,
|
||||
long? FromSequence = null,
|
||||
long? ToSequence = null,
|
||||
DateTimeOffset? FromTimestamp = null,
|
||||
DateTimeOffset? ToTimestamp = null,
|
||||
IReadOnlyList<Guid>? ChainIds = null,
|
||||
IReadOnlyList<string>? EventTypes = null,
|
||||
bool IncludePayload = true,
|
||||
int PageSize = 1000);
|
||||
|
||||
/// <summary>
|
||||
/// Replayed event record.
|
||||
/// </summary>
|
||||
public sealed record ReplayEvent(
|
||||
Guid EventId,
|
||||
long SequenceNumber,
|
||||
Guid ChainId,
|
||||
int ChainSequence,
|
||||
string EventType,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? ActorId,
|
||||
string? ActorType,
|
||||
string? ArtifactId,
|
||||
string? FindingId,
|
||||
string? PolicyVersion,
|
||||
string EventHash,
|
||||
string PreviousHash,
|
||||
object? Payload);
|
||||
|
||||
/// <summary>
|
||||
/// Replay metadata.
|
||||
/// </summary>
|
||||
public sealed record ReplayMetadata(
|
||||
long FromSequence,
|
||||
long ToSequence,
|
||||
long EventsCount,
|
||||
bool HasMore,
|
||||
long ReplayDurationMs);
|
||||
|
||||
/// <summary>
|
||||
/// Request for computing diff between two points.
|
||||
/// </summary>
|
||||
public sealed record DiffRequest(
|
||||
string TenantId,
|
||||
DiffPoint From,
|
||||
DiffPoint To,
|
||||
IReadOnlyList<EntityType>? EntityTypes = null,
|
||||
bool IncludeUnchanged = false,
|
||||
DiffOutputFormat OutputFormat = DiffOutputFormat.Summary);
|
||||
|
||||
/// <summary>
|
||||
/// Diff point specification.
|
||||
/// </summary>
|
||||
public sealed record DiffPoint(
|
||||
DateTimeOffset? Timestamp = null,
|
||||
long? SequenceNumber = null,
|
||||
Guid? SnapshotId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Diff output format.
|
||||
/// </summary>
|
||||
public enum DiffOutputFormat
|
||||
{
|
||||
Summary,
|
||||
Detailed,
|
||||
Full
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff summary counts.
|
||||
/// </summary>
|
||||
public sealed record DiffSummary(
|
||||
int Added,
|
||||
int Modified,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
Dictionary<EntityType, DiffCounts>? ByEntityType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Diff counts per entity type.
|
||||
/// </summary>
|
||||
public sealed record DiffCounts(int Added, int Modified, int Removed);
|
||||
|
||||
/// <summary>
|
||||
/// Individual diff entry.
|
||||
/// </summary>
|
||||
public sealed record DiffEntry(
|
||||
EntityType EntityType,
|
||||
string EntityId,
|
||||
DiffChangeType ChangeType,
|
||||
object? FromState,
|
||||
object? ToState,
|
||||
IReadOnlyList<string>? ChangedFields);
|
||||
|
||||
/// <summary>
|
||||
/// Type of change in a diff.
|
||||
/// </summary>
|
||||
public enum DiffChangeType
|
||||
{
|
||||
Added,
|
||||
Modified,
|
||||
Removed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff response.
|
||||
/// </summary>
|
||||
public sealed record DiffResponse(
|
||||
QueryPoint FromPoint,
|
||||
QueryPoint ToPoint,
|
||||
DiffSummary Summary,
|
||||
IReadOnlyList<DiffEntry>? Changes,
|
||||
string? NextPageToken);
|
||||
|
||||
/// <summary>
|
||||
/// Changelog entry.
|
||||
/// </summary>
|
||||
public sealed record ChangeLogEntry(
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
EntityType EntityType,
|
||||
string EntityId,
|
||||
string EventType,
|
||||
string? EventHash,
|
||||
string? ActorId,
|
||||
string? Summary);
|
||||
|
||||
/// <summary>
|
||||
/// Staleness check result.
|
||||
/// </summary>
|
||||
public sealed record StalenessResult(
|
||||
bool IsStale,
|
||||
DateTimeOffset CheckedAt,
|
||||
DateTimeOffset? LastEventAt,
|
||||
TimeSpan StalenessThreshold,
|
||||
TimeSpan? StalenessDuration,
|
||||
Dictionary<EntityType, EntityStaleness>? ByEntityType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Staleness per entity type.
|
||||
/// </summary>
|
||||
public sealed record EntityStaleness(
|
||||
bool IsStale,
|
||||
DateTimeOffset? LastEventAt,
|
||||
long EventsBehind);
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing snapshots.
|
||||
/// </summary>
|
||||
public sealed record SnapshotListQuery(
|
||||
string TenantId,
|
||||
SnapshotStatus? Status = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
int PageSize = 100,
|
||||
string? PageToken = null);
|
||||
@@ -0,0 +1,184 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Record representing an attestation pointer linking a finding to a verification report or attestation envelope.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerRecord(
|
||||
string TenantId,
|
||||
Guid PointerId,
|
||||
string FindingId,
|
||||
AttestationType AttestationType,
|
||||
AttestationRelationship Relationship,
|
||||
AttestationRef AttestationRef,
|
||||
VerificationResult? VerificationResult,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedBy,
|
||||
Dictionary<string, object>? Metadata = null,
|
||||
Guid? LedgerEventId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of attestation being pointed to.
|
||||
/// </summary>
|
||||
public enum AttestationType
|
||||
{
|
||||
VerificationReport,
|
||||
DsseEnvelope,
|
||||
SlsaProvenance,
|
||||
VexAttestation,
|
||||
SbomAttestation,
|
||||
ScanAttestation,
|
||||
PolicyAttestation,
|
||||
ApprovalAttestation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic relationship between finding and attestation.
|
||||
/// </summary>
|
||||
public enum AttestationRelationship
|
||||
{
|
||||
VerifiedBy,
|
||||
AttestedBy,
|
||||
SignedBy,
|
||||
ApprovedBy,
|
||||
DerivedFrom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an attestation artifact.
|
||||
/// </summary>
|
||||
public sealed record AttestationRef(
|
||||
string Digest,
|
||||
Guid? AttestationId = null,
|
||||
string? StorageUri = null,
|
||||
string? PayloadType = null,
|
||||
string? PredicateType = null,
|
||||
IReadOnlyList<string>? SubjectDigests = null,
|
||||
SignerInfo? SignerInfo = null,
|
||||
RekorEntryRef? RekorEntry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Information about the attestation signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfo(
|
||||
string? KeyId = null,
|
||||
string? Issuer = null,
|
||||
string? Subject = null,
|
||||
IReadOnlyList<string>? CertificateChain = null,
|
||||
DateTimeOffset? SignedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record RekorEntryRef(
|
||||
long? LogIndex = null,
|
||||
string? LogId = null,
|
||||
string? Uuid = null,
|
||||
long? IntegratedTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationResult(
|
||||
bool Verified,
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? Verifier = null,
|
||||
string? VerifierVersion = null,
|
||||
string? PolicyRef = null,
|
||||
IReadOnlyList<VerificationCheck>? Checks = null,
|
||||
IReadOnlyList<string>? Warnings = null,
|
||||
IReadOnlyList<string>? Errors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheck(
|
||||
VerificationCheckType CheckType,
|
||||
bool Passed,
|
||||
string? Details = null,
|
||||
Dictionary<string, object>? Evidence = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of verification check performed.
|
||||
/// </summary>
|
||||
public enum VerificationCheckType
|
||||
{
|
||||
SignatureValid,
|
||||
CertificateValid,
|
||||
CertificateNotExpired,
|
||||
CertificateNotRevoked,
|
||||
RekorEntryValid,
|
||||
TimestampValid,
|
||||
PolicyMet,
|
||||
IdentityVerified,
|
||||
IssuerTrusted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerInput(
|
||||
string TenantId,
|
||||
string FindingId,
|
||||
AttestationType AttestationType,
|
||||
AttestationRelationship Relationship,
|
||||
AttestationRef AttestationRef,
|
||||
VerificationResult? VerificationResult = null,
|
||||
string? CreatedBy = null,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerResult(
|
||||
bool Success,
|
||||
Guid? PointerId,
|
||||
Guid? LedgerEventId,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of attestations for a finding.
|
||||
/// </summary>
|
||||
public sealed record FindingAttestationSummary(
|
||||
string FindingId,
|
||||
int AttestationCount,
|
||||
int VerifiedCount,
|
||||
DateTimeOffset? LatestAttestation,
|
||||
IReadOnlyList<AttestationType> AttestationTypes,
|
||||
OverallVerificationStatus OverallVerificationStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Overall verification status for a finding's attestations.
|
||||
/// </summary>
|
||||
public enum OverallVerificationStatus
|
||||
{
|
||||
AllVerified,
|
||||
PartiallyVerified,
|
||||
NoneVerified,
|
||||
NoAttestations
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerQuery(
|
||||
string TenantId,
|
||||
IReadOnlyList<string>? FindingIds = null,
|
||||
IReadOnlyList<AttestationType>? AttestationTypes = null,
|
||||
AttestationVerificationFilter? VerificationStatus = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
string? SignerIdentity = null,
|
||||
string? PredicateType = null,
|
||||
int Limit = 100,
|
||||
int Offset = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Filter for verification status.
|
||||
/// </summary>
|
||||
public enum AttestationVerificationFilter
|
||||
{
|
||||
Any,
|
||||
Verified,
|
||||
Unverified,
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing attestation pointers linking findings to verification reports and attestation envelopes.
|
||||
/// </summary>
|
||||
public interface IAttestationPointerRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts a new attestation pointer.
|
||||
/// </summary>
|
||||
Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attestation pointer by ID.
|
||||
/// </summary>
|
||||
Task<AttestationPointerRecord?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all attestation pointers for a finding.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation pointers by attestation digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(
|
||||
string tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Searches attestation pointers based on query parameters.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(
|
||||
AttestationPointerQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summary for a finding.
|
||||
/// </summary>
|
||||
Task<FindingAttestationSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summaries for multiple findings.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an attestation pointer already exists (for idempotency).
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string digest,
|
||||
AttestationType attestationType,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the verification result for an attestation pointer.
|
||||
/// </summary>
|
||||
Task UpdateVerificationResultAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
VerificationResult verificationResult,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of attestation pointers for a finding.
|
||||
/// </summary>
|
||||
Task<int> GetCountAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets findings that have attestations matching the criteria.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
|
||||
string tenantId,
|
||||
AttestationVerificationFilter? verificationFilter,
|
||||
IReadOnlyList<AttestationType>? attestationTypes,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres-backed repository for attestation pointers.
|
||||
/// </summary>
|
||||
public sealed class PostgresAttestationPointerRepository : IAttestationPointerRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresAttestationPointerRepository> _logger;
|
||||
|
||||
public PostgresAttestationPointerRepository(
|
||||
LedgerDataSource dataSource,
|
||||
ILogger<PostgresAttestationPointerRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO ledger_attestation_pointers (
|
||||
tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
) VALUES (
|
||||
@tenant_id, @pointer_id, @finding_id, @attestation_type, @relationship,
|
||||
@attestation_ref::jsonb, @verification_result::jsonb, @created_at, @created_by,
|
||||
@metadata::jsonb, @ledger_event_id
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
record.TenantId, "attestation_pointer_write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", record.TenantId);
|
||||
command.Parameters.AddWithValue("pointer_id", record.PointerId);
|
||||
command.Parameters.AddWithValue("finding_id", record.FindingId);
|
||||
command.Parameters.AddWithValue("attestation_type", record.AttestationType.ToString());
|
||||
command.Parameters.AddWithValue("relationship", record.Relationship.ToString());
|
||||
command.Parameters.AddWithValue("attestation_ref", JsonSerializer.Serialize(record.AttestationRef, JsonOptions));
|
||||
command.Parameters.AddWithValue("verification_result",
|
||||
record.VerificationResult is not null
|
||||
? JsonSerializer.Serialize(record.VerificationResult, JsonOptions)
|
||||
: DBNull.Value);
|
||||
command.Parameters.AddWithValue("created_at", record.CreatedAt);
|
||||
command.Parameters.AddWithValue("created_by", record.CreatedBy);
|
||||
command.Parameters.AddWithValue("metadata",
|
||||
record.Metadata is not null
|
||||
? JsonSerializer.Serialize(record.Metadata, JsonOptions)
|
||||
: DBNull.Value);
|
||||
command.Parameters.AddWithValue("ledger_event_id",
|
||||
record.LedgerEventId.HasValue ? record.LedgerEventId.Value : DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Inserted attestation pointer {PointerId} for finding {FindingId} with type {AttestationType}",
|
||||
record.PointerId, record.FindingId, record.AttestationType);
|
||||
}
|
||||
|
||||
public async Task<AttestationPointerRecord?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
const string sql = """
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND pointer_id = @pointer_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("pointer_id", pointerId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return ReadRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
const string sql = """
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = @finding_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(
|
||||
string tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
const string sql = """
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND attestation_ref->>'digest' = @digest
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("digest", digest);
|
||||
|
||||
return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(
|
||||
AttestationPointerQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
|
||||
|
||||
var sqlBuilder = new StringBuilder("""
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", query.TenantId) { NpgsqlDbType = NpgsqlDbType.Text }
|
||||
};
|
||||
|
||||
if (query.FindingIds is { Count: > 0 })
|
||||
{
|
||||
sqlBuilder.Append(" AND finding_id = ANY(@finding_ids)");
|
||||
parameters.Add(new NpgsqlParameter<string[]>("finding_ids", query.FindingIds.ToArray()));
|
||||
}
|
||||
|
||||
if (query.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_type = ANY(@attestation_types)");
|
||||
parameters.Add(new NpgsqlParameter<string[]>("attestation_types",
|
||||
query.AttestationTypes.Select(t => t.ToString()).ToArray()));
|
||||
}
|
||||
|
||||
if (query.VerificationStatus.HasValue && query.VerificationStatus.Value != AttestationVerificationFilter.Any)
|
||||
{
|
||||
sqlBuilder.Append(query.VerificationStatus.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = true",
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
" AND verification_result IS NULL",
|
||||
AttestationVerificationFilter.Failed =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = false",
|
||||
_ => ""
|
||||
});
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND created_at >= @created_after");
|
||||
parameters.Add(new NpgsqlParameter<DateTimeOffset>("created_after", query.CreatedAfter.Value)
|
||||
{
|
||||
NpgsqlDbType = NpgsqlDbType.TimestampTz
|
||||
});
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND created_at <= @created_before");
|
||||
parameters.Add(new NpgsqlParameter<DateTimeOffset>("created_before", query.CreatedBefore.Value)
|
||||
{
|
||||
NpgsqlDbType = NpgsqlDbType.TimestampTz
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.SignerIdentity))
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_ref->'signer_info'->>'subject' = @signer_identity");
|
||||
parameters.Add(new NpgsqlParameter<string>("signer_identity", query.SignerIdentity));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PredicateType))
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_ref->>'predicate_type' = @predicate_type");
|
||||
parameters.Add(new NpgsqlParameter<string>("predicate_type", query.PredicateType));
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY created_at DESC");
|
||||
sqlBuilder.Append(" LIMIT @limit OFFSET @offset");
|
||||
parameters.Add(new NpgsqlParameter<int>("limit", query.Limit));
|
||||
parameters.Add(new NpgsqlParameter<int>("offset", query.Offset));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
query.TenantId, "attestation_pointer_search", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sqlBuilder.ToString(), connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
command.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<FindingAttestationSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = true) as verified_count,
|
||||
MAX(created_at) as latest_attestation,
|
||||
array_agg(DISTINCT attestation_type) as attestation_types
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = @finding_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_summary", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var totalCount = reader.GetInt32(0);
|
||||
var verifiedCount = reader.GetInt32(1);
|
||||
var latestAttestation = reader.IsDBNull(2)
|
||||
? (DateTimeOffset?)null
|
||||
: reader.GetFieldValue<DateTimeOffset>(2);
|
||||
var attestationTypesRaw = reader.IsDBNull(3)
|
||||
? Array.Empty<string>()
|
||||
: reader.GetFieldValue<string[]>(3);
|
||||
|
||||
var attestationTypes = attestationTypesRaw
|
||||
.Where(t => Enum.TryParse<AttestationType>(t, out _))
|
||||
.Select(t => Enum.Parse<AttestationType>(t))
|
||||
.ToList();
|
||||
|
||||
var overallStatus = totalCount switch
|
||||
{
|
||||
0 => OverallVerificationStatus.NoAttestations,
|
||||
_ when verifiedCount == totalCount => OverallVerificationStatus.AllVerified,
|
||||
_ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
|
||||
_ => OverallVerificationStatus.NoneVerified
|
||||
};
|
||||
|
||||
return new FindingAttestationSummary(
|
||||
findingId,
|
||||
totalCount,
|
||||
verifiedCount,
|
||||
latestAttestation,
|
||||
attestationTypes,
|
||||
overallStatus);
|
||||
}
|
||||
|
||||
return new FindingAttestationSummary(
|
||||
findingId,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
Array.Empty<AttestationType>(),
|
||||
OverallVerificationStatus.NoAttestations);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(findingIds);
|
||||
|
||||
if (findingIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<FindingAttestationSummary>();
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
finding_id,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = true) as verified_count,
|
||||
MAX(created_at) as latest_attestation,
|
||||
array_agg(DISTINCT attestation_type) as attestation_types
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = ANY(@finding_ids)
|
||||
GROUP BY finding_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_summaries", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_ids", findingIds.ToArray());
|
||||
|
||||
var results = new List<FindingAttestationSummary>();
|
||||
var foundIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var fid = reader.GetString(0);
|
||||
foundIds.Add(fid);
|
||||
|
||||
var totalCount = reader.GetInt32(1);
|
||||
var verifiedCount = reader.GetInt32(2);
|
||||
var latestAttestation = reader.IsDBNull(3)
|
||||
? (DateTimeOffset?)null
|
||||
: reader.GetFieldValue<DateTimeOffset>(3);
|
||||
var attestationTypesRaw = reader.IsDBNull(4)
|
||||
? Array.Empty<string>()
|
||||
: reader.GetFieldValue<string[]>(4);
|
||||
|
||||
var attestationTypes = attestationTypesRaw
|
||||
.Where(t => Enum.TryParse<AttestationType>(t, out _))
|
||||
.Select(t => Enum.Parse<AttestationType>(t))
|
||||
.ToList();
|
||||
|
||||
var overallStatus = totalCount switch
|
||||
{
|
||||
0 => OverallVerificationStatus.NoAttestations,
|
||||
_ when verifiedCount == totalCount => OverallVerificationStatus.AllVerified,
|
||||
_ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
|
||||
_ => OverallVerificationStatus.NoneVerified
|
||||
};
|
||||
|
||||
results.Add(new FindingAttestationSummary(
|
||||
fid,
|
||||
totalCount,
|
||||
verifiedCount,
|
||||
latestAttestation,
|
||||
attestationTypes,
|
||||
overallStatus));
|
||||
}
|
||||
|
||||
// Add empty summaries for findings without attestations
|
||||
foreach (var fid in findingIds.Where(f => !foundIds.Contains(f)))
|
||||
{
|
||||
results.Add(new FindingAttestationSummary(
|
||||
fid,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
Array.Empty<AttestationType>(),
|
||||
OverallVerificationStatus.NoAttestations));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string digest,
|
||||
AttestationType attestationType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
const string sql = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND finding_id = @finding_id
|
||||
AND attestation_ref->>'digest' = @digest
|
||||
AND attestation_type = @attestation_type
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_exists", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
command.Parameters.AddWithValue("digest", digest);
|
||||
command.Parameters.AddWithValue("attestation_type", attestationType.ToString());
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
public async Task UpdateVerificationResultAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
VerificationResult verificationResult,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(verificationResult);
|
||||
|
||||
const string sql = """
|
||||
UPDATE ledger_attestation_pointers
|
||||
SET verification_result = @verification_result::jsonb
|
||||
WHERE tenant_id = @tenant_id AND pointer_id = @pointer_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_update", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("pointer_id", pointerId);
|
||||
command.Parameters.AddWithValue("verification_result",
|
||||
JsonSerializer.Serialize(verificationResult, JsonOptions));
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Updated verification result for attestation pointer {PointerId}, verified={Verified}",
|
||||
pointerId, verificationResult.Verified);
|
||||
}
|
||||
|
||||
public async Task<int> GetCountAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = @finding_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_count", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
|
||||
string tenantId,
|
||||
AttestationVerificationFilter? verificationFilter,
|
||||
IReadOnlyList<AttestationType>? attestationTypes,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var sqlBuilder = new StringBuilder("""
|
||||
SELECT DISTINCT finding_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text }
|
||||
};
|
||||
|
||||
if (attestationTypes is { Count: > 0 })
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_type = ANY(@attestation_types)");
|
||||
parameters.Add(new NpgsqlParameter<string[]>("attestation_types",
|
||||
attestationTypes.Select(t => t.ToString()).ToArray()));
|
||||
}
|
||||
|
||||
if (verificationFilter.HasValue && verificationFilter.Value != AttestationVerificationFilter.Any)
|
||||
{
|
||||
sqlBuilder.Append(verificationFilter.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = true",
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
" AND verification_result IS NULL",
|
||||
AttestationVerificationFilter.Failed =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = false",
|
||||
_ => ""
|
||||
});
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY finding_id LIMIT @limit OFFSET @offset");
|
||||
parameters.Add(new NpgsqlParameter<int>("limit", limit));
|
||||
parameters.Add(new NpgsqlParameter<int>("offset", offset));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_findings", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sqlBuilder.ToString(), connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
command.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var results = new List<string>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AttestationPointerRecord>> ReadRecordsAsync(
|
||||
NpgsqlCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<AttestationPointerRecord>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(ReadRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static AttestationPointerRecord ReadRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var tenantId = reader.GetString(0);
|
||||
var pointerId = reader.GetGuid(1);
|
||||
var findingId = reader.GetString(2);
|
||||
var attestationType = Enum.Parse<AttestationType>(reader.GetString(3));
|
||||
var relationship = Enum.Parse<AttestationRelationship>(reader.GetString(4));
|
||||
|
||||
var attestationRefJson = reader.GetString(5);
|
||||
var attestationRef = JsonSerializer.Deserialize<AttestationRef>(attestationRefJson, JsonOptions)!;
|
||||
|
||||
VerificationResult? verificationResult = null;
|
||||
if (!reader.IsDBNull(6))
|
||||
{
|
||||
var verificationResultJson = reader.GetString(6);
|
||||
verificationResult = JsonSerializer.Deserialize<VerificationResult>(verificationResultJson, JsonOptions);
|
||||
}
|
||||
|
||||
var createdAt = reader.GetFieldValue<DateTimeOffset>(7);
|
||||
var createdBy = reader.GetString(8);
|
||||
|
||||
Dictionary<string, object>? metadata = null;
|
||||
if (!reader.IsDBNull(9))
|
||||
{
|
||||
var metadataJson = reader.GetString(9);
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metadataJson, JsonOptions);
|
||||
}
|
||||
|
||||
Guid? ledgerEventId = reader.IsDBNull(10) ? null : reader.GetGuid(10);
|
||||
|
||||
return new AttestationPointerRecord(
|
||||
tenantId,
|
||||
pointerId,
|
||||
findingId,
|
||||
attestationType,
|
||||
relationship,
|
||||
attestationRef,
|
||||
verificationResult,
|
||||
createdAt,
|
||||
createdBy,
|
||||
metadata,
|
||||
ledgerEventId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of snapshot repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresSnapshotRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LedgerSnapshot> CreateAsync(
|
||||
string tenantId,
|
||||
CreateSnapshotInput input,
|
||||
long currentSequence,
|
||||
DateTimeOffset currentTimestamp,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var snapshotId = Guid.NewGuid();
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var expiresAt = input.ExpiresIn.HasValue
|
||||
? createdAt.Add(input.ExpiresIn.Value)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
var sequenceNumber = input.AtSequence ?? currentSequence;
|
||||
var timestamp = input.AtTimestamp ?? currentTimestamp;
|
||||
|
||||
var initialStats = new SnapshotStatistics(0, 0, 0, 0, 0, 0);
|
||||
var metadataJson = input.Metadata != null
|
||||
? JsonSerializer.Serialize(input.Metadata, _jsonOptions)
|
||||
: null;
|
||||
var entityTypesJson = input.IncludeEntityTypes != null
|
||||
? JsonSerializer.Serialize(input.IncludeEntityTypes.Select(e => e.ToString()).ToList(), _jsonOptions)
|
||||
: null;
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO ledger_snapshots (
|
||||
tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata, include_entity_types, sign_requested
|
||||
) VALUES (
|
||||
@tenantId, @snapshotId, @label, @description, @status,
|
||||
@createdAt, @expiresAt, @sequenceNumber, @timestamp,
|
||||
@findingsCount, @vexCount, @advisoriesCount,
|
||||
@sbomsCount, @eventsCount, @sizeBytes,
|
||||
@merkleRoot, @dsseDigest, @metadata::jsonb, @entityTypes::jsonb, @sign
|
||||
)
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("label", (object?)input.Label ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("description", (object?)input.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", SnapshotStatus.Creating.ToString());
|
||||
cmd.Parameters.AddWithValue("createdAt", createdAt);
|
||||
cmd.Parameters.AddWithValue("expiresAt", (object?)expiresAt ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("sequenceNumber", sequenceNumber);
|
||||
cmd.Parameters.AddWithValue("timestamp", timestamp);
|
||||
cmd.Parameters.AddWithValue("findingsCount", initialStats.FindingsCount);
|
||||
cmd.Parameters.AddWithValue("vexCount", initialStats.VexStatementsCount);
|
||||
cmd.Parameters.AddWithValue("advisoriesCount", initialStats.AdvisoriesCount);
|
||||
cmd.Parameters.AddWithValue("sbomsCount", initialStats.SbomsCount);
|
||||
cmd.Parameters.AddWithValue("eventsCount", initialStats.EventsCount);
|
||||
cmd.Parameters.AddWithValue("sizeBytes", initialStats.SizeBytes);
|
||||
cmd.Parameters.AddWithValue("merkleRoot", DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("dsseDigest", DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("metadata", (object?)metadataJson ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("entityTypes", (object?)entityTypesJson ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("sign", input.Sign);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
return new LedgerSnapshot(
|
||||
tenantId,
|
||||
snapshotId,
|
||||
input.Label,
|
||||
input.Description,
|
||||
SnapshotStatus.Creating,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
sequenceNumber,
|
||||
timestamp,
|
||||
initialStats,
|
||||
null,
|
||||
null,
|
||||
input.Metadata);
|
||||
}
|
||||
|
||||
public async Task<LedgerSnapshot?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata
|
||||
FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
|
||||
return MapSnapshot(reader);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = new StringBuilder("""
|
||||
SELECT tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata
|
||||
FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenantId", query.TenantId)
|
||||
};
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
sql.Append(" AND status = @status");
|
||||
parameters.Add(new NpgsqlParameter("status", query.Status.Value.ToString()));
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
sql.Append(" AND created_at >= @createdAfter");
|
||||
parameters.Add(new NpgsqlParameter("createdAfter", query.CreatedAfter.Value));
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
sql.Append(" AND created_at < @createdBefore");
|
||||
parameters.Add(new NpgsqlParameter("createdBefore", query.CreatedBefore.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.PageToken))
|
||||
{
|
||||
if (Guid.TryParse(query.PageToken, out var lastId))
|
||||
{
|
||||
sql.Append(" AND snapshot_id > @lastId");
|
||||
parameters.Add(new NpgsqlParameter("lastId", lastId));
|
||||
}
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY created_at DESC, snapshot_id");
|
||||
sql.Append(" LIMIT @limit");
|
||||
parameters.Add(new NpgsqlParameter("limit", query.PageSize + 1));
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var snapshots = new List<LedgerSnapshot>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct) && snapshots.Count < query.PageSize)
|
||||
{
|
||||
snapshots.Add(MapSnapshot(reader));
|
||||
}
|
||||
|
||||
string? nextPageToken = null;
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
nextPageToken = snapshots.Last().SnapshotId.ToString();
|
||||
}
|
||||
|
||||
return (snapshots, nextPageToken);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatus newStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET status = @status, updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("status", newStatus.ToString());
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatisticsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatistics statistics,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET findings_count = @findingsCount,
|
||||
vex_statements_count = @vexCount,
|
||||
advisories_count = @advisoriesCount,
|
||||
sboms_count = @sbomsCount,
|
||||
events_count = @eventsCount,
|
||||
size_bytes = @sizeBytes,
|
||||
updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("findingsCount", statistics.FindingsCount);
|
||||
cmd.Parameters.AddWithValue("vexCount", statistics.VexStatementsCount);
|
||||
cmd.Parameters.AddWithValue("advisoriesCount", statistics.AdvisoriesCount);
|
||||
cmd.Parameters.AddWithValue("sbomsCount", statistics.SbomsCount);
|
||||
cmd.Parameters.AddWithValue("eventsCount", statistics.EventsCount);
|
||||
cmd.Parameters.AddWithValue("sizeBytes", statistics.SizeBytes);
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> SetMerkleRootAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
string merkleRoot,
|
||||
string? dsseDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET merkle_root = @merkleRoot,
|
||||
dsse_digest = @dsseDigest,
|
||||
updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("merkleRoot", merkleRoot);
|
||||
cmd.Parameters.AddWithValue("dsseDigest", (object?)dsseDigest ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<int> ExpireSnapshotsAsync(
|
||||
DateTimeOffset cutoff,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET status = @expiredStatus, updated_at = @updatedAt
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < @cutoff
|
||||
AND status = @availableStatus
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("expiredStatus", SnapshotStatus.Expired.ToString());
|
||||
cmd.Parameters.AddWithValue("availableStatus", SnapshotStatus.Available.ToString());
|
||||
cmd.Parameters.AddWithValue("cutoff", cutoff);
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET status = @deletedStatus, updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("deletedStatus", SnapshotStatus.Deleted.ToString());
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<LedgerSnapshot?> GetLatestAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata
|
||||
FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId AND status = @status
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("status", SnapshotStatus.Available.ToString());
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
|
||||
return MapSnapshot(reader);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT 1 FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct);
|
||||
}
|
||||
|
||||
private LedgerSnapshot MapSnapshot(NpgsqlDataReader reader)
|
||||
{
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
Dictionary<string, object>? metadata = null;
|
||||
if (!string.IsNullOrEmpty(metadataJson))
|
||||
{
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metadataJson, _jsonOptions);
|
||||
}
|
||||
|
||||
return new LedgerSnapshot(
|
||||
TenantId: reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
SnapshotId: reader.GetGuid(reader.GetOrdinal("snapshot_id")),
|
||||
Label: reader.IsDBNull(reader.GetOrdinal("label")) ? null : reader.GetString(reader.GetOrdinal("label")),
|
||||
Description: reader.IsDBNull(reader.GetOrdinal("description")) ? null : reader.GetString(reader.GetOrdinal("description")),
|
||||
Status: Enum.Parse<SnapshotStatus>(reader.GetString(reader.GetOrdinal("status"))),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
ExpiresAt: reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("snapshot_timestamp")),
|
||||
Statistics: new SnapshotStatistics(
|
||||
FindingsCount: reader.GetInt64(reader.GetOrdinal("findings_count")),
|
||||
VexStatementsCount: reader.GetInt64(reader.GetOrdinal("vex_statements_count")),
|
||||
AdvisoriesCount: reader.GetInt64(reader.GetOrdinal("advisories_count")),
|
||||
SbomsCount: reader.GetInt64(reader.GetOrdinal("sboms_count")),
|
||||
EventsCount: reader.GetInt64(reader.GetOrdinal("events_count")),
|
||||
SizeBytes: reader.GetInt64(reader.GetOrdinal("size_bytes"))),
|
||||
MerkleRoot: reader.IsDBNull(reader.GetOrdinal("merkle_root")) ? null : reader.GetString(reader.GetOrdinal("merkle_root")),
|
||||
DsseDigest: reader.IsDBNull(reader.GetOrdinal("dsse_digest")) ? null : reader.GetString(reader.GetOrdinal("dsse_digest")),
|
||||
Metadata: metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of time-travel repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresTimeTravelRepository : ITimeTravelRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ISnapshotRepository _snapshotRepository;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresTimeTravelRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ISnapshotRepository snapshotRepository)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_snapshotRepository = snapshotRepository;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<QueryPoint> GetCurrentPointAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COALESCE(MAX(sequence_number), 0) as seq,
|
||||
COALESCE(MAX(recorded_at), NOW()) as ts
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("ts")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("seq")));
|
||||
}
|
||||
|
||||
public async Task<QueryPoint?> ResolveQueryPointAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? timestamp,
|
||||
long? sequence,
|
||||
Guid? snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// If snapshot ID is provided, get point from snapshot
|
||||
if (snapshotId.HasValue)
|
||||
{
|
||||
var snapshot = await _snapshotRepository.GetByIdAsync(tenantId, snapshotId.Value, ct);
|
||||
if (snapshot == null)
|
||||
return null;
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: snapshot.Timestamp,
|
||||
SequenceNumber: snapshot.SequenceNumber,
|
||||
SnapshotId: snapshotId);
|
||||
}
|
||||
|
||||
// If sequence is provided, get timestamp for that sequence
|
||||
if (sequence.HasValue)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT recorded_at FROM ledger_events
|
||||
WHERE tenant_id = @tenantId AND sequence_number = @seq
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("seq", sequence.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(0),
|
||||
SequenceNumber: sequence.Value);
|
||||
}
|
||||
|
||||
// If timestamp is provided, find the sequence at that point
|
||||
if (timestamp.HasValue)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT sequence_number, recorded_at FROM ledger_events
|
||||
WHERE tenant_id = @tenantId AND recorded_at <= @ts
|
||||
ORDER BY sequence_number DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("ts", timestamp.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
// No events before timestamp, return point at 0
|
||||
return new QueryPoint(timestamp.Value, 0);
|
||||
}
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(1),
|
||||
SequenceNumber: reader.GetInt64(0));
|
||||
}
|
||||
|
||||
// No constraints - return current point
|
||||
return await GetCurrentPointAsync(tenantId, ct);
|
||||
}
|
||||
|
||||
public async Task<HistoricalQueryResponse<FindingHistoryItem>> QueryFindingsAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var queryPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.AtTimestamp,
|
||||
request.AtSequence,
|
||||
request.SnapshotId,
|
||||
ct);
|
||||
|
||||
if (queryPoint == null)
|
||||
{
|
||||
return new HistoricalQueryResponse<FindingHistoryItem>(
|
||||
new QueryPoint(DateTimeOffset.UtcNow, 0),
|
||||
EntityType.Finding,
|
||||
Array.Empty<FindingHistoryItem>(),
|
||||
null,
|
||||
0);
|
||||
}
|
||||
|
||||
// Query findings state at the sequence point using event sourcing
|
||||
var sql = new StringBuilder("""
|
||||
WITH finding_state AS (
|
||||
SELECT
|
||||
e.finding_id,
|
||||
e.artifact_id,
|
||||
e.payload->>'vulnId' as vuln_id,
|
||||
e.payload->>'status' as status,
|
||||
(e.payload->>'severity')::decimal as severity,
|
||||
e.policy_version,
|
||||
MIN(e.recorded_at) OVER (PARTITION BY e.finding_id) as first_seen,
|
||||
e.recorded_at as last_updated,
|
||||
e.payload->'labels' as labels,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.finding_id ORDER BY e.sequence_number DESC) as rn
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number <= @seq
|
||||
AND e.finding_id IS NOT NULL
|
||||
)
|
||||
SELECT finding_id, artifact_id, vuln_id, status, severity,
|
||||
policy_version, first_seen, last_updated, labels
|
||||
FROM finding_state
|
||||
WHERE rn = 1
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenantId", request.TenantId),
|
||||
new("seq", queryPoint.SequenceNumber)
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
if (request.Filters != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.Filters.Status))
|
||||
{
|
||||
sql.Append(" AND status = @status");
|
||||
parameters.Add(new NpgsqlParameter("status", request.Filters.Status));
|
||||
}
|
||||
|
||||
if (request.Filters.SeverityMin.HasValue)
|
||||
{
|
||||
sql.Append(" AND severity >= @sevMin");
|
||||
parameters.Add(new NpgsqlParameter("sevMin", request.Filters.SeverityMin.Value));
|
||||
}
|
||||
|
||||
if (request.Filters.SeverityMax.HasValue)
|
||||
{
|
||||
sql.Append(" AND severity <= @sevMax");
|
||||
parameters.Add(new NpgsqlParameter("sevMax", request.Filters.SeverityMax.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Filters.ArtifactId))
|
||||
{
|
||||
sql.Append(" AND artifact_id = @artifactId");
|
||||
parameters.Add(new NpgsqlParameter("artifactId", request.Filters.ArtifactId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Filters.VulnId))
|
||||
{
|
||||
sql.Append(" AND vuln_id = @vulnId");
|
||||
parameters.Add(new NpgsqlParameter("vulnId", request.Filters.VulnId));
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (!string.IsNullOrEmpty(request.PageToken))
|
||||
{
|
||||
sql.Append(" AND finding_id > @lastId");
|
||||
parameters.Add(new NpgsqlParameter("lastId", request.PageToken));
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY finding_id LIMIT @limit");
|
||||
parameters.Add(new NpgsqlParameter("limit", request.PageSize + 1));
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var items = new List<FindingHistoryItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct) && items.Count < request.PageSize)
|
||||
{
|
||||
var labelsJson = reader.IsDBNull(reader.GetOrdinal("labels"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("labels"));
|
||||
|
||||
items.Add(new FindingHistoryItem(
|
||||
FindingId: reader.GetString(reader.GetOrdinal("finding_id")),
|
||||
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||
VulnId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||
Status: reader.GetString(reader.GetOrdinal("status")),
|
||||
Severity: reader.IsDBNull(reader.GetOrdinal("severity")) ? null : reader.GetDecimal(reader.GetOrdinal("severity")),
|
||||
PolicyVersion: reader.IsDBNull(reader.GetOrdinal("policy_version")) ? null : reader.GetString(reader.GetOrdinal("policy_version")),
|
||||
FirstSeen: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("first_seen")),
|
||||
LastUpdated: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_updated")),
|
||||
Labels: string.IsNullOrEmpty(labelsJson)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(labelsJson, _jsonOptions)));
|
||||
}
|
||||
|
||||
string? nextPageToken = null;
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
nextPageToken = items.Last().FindingId;
|
||||
}
|
||||
|
||||
return new HistoricalQueryResponse<FindingHistoryItem>(
|
||||
queryPoint,
|
||||
EntityType.Finding,
|
||||
items,
|
||||
nextPageToken,
|
||||
items.Count);
|
||||
}
|
||||
|
||||
public async Task<HistoricalQueryResponse<VexHistoryItem>> QueryVexAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var queryPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.AtTimestamp,
|
||||
request.AtSequence,
|
||||
request.SnapshotId,
|
||||
ct);
|
||||
|
||||
if (queryPoint == null)
|
||||
{
|
||||
return new HistoricalQueryResponse<VexHistoryItem>(
|
||||
new QueryPoint(DateTimeOffset.UtcNow, 0),
|
||||
EntityType.Vex,
|
||||
Array.Empty<VexHistoryItem>(),
|
||||
null,
|
||||
0);
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
WITH vex_state AS (
|
||||
SELECT
|
||||
e.payload->>'statementId' as statement_id,
|
||||
e.payload->>'vulnId' as vuln_id,
|
||||
e.payload->>'productId' as product_id,
|
||||
e.payload->>'status' as status,
|
||||
e.payload->>'justification' as justification,
|
||||
(e.payload->>'issuedAt')::timestamptz as issued_at,
|
||||
(e.payload->>'expiresAt')::timestamptz as expires_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.payload->>'statementId' ORDER BY e.sequence_number DESC) as rn
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number <= @seq
|
||||
AND e.event_type LIKE 'vex.%'
|
||||
)
|
||||
SELECT statement_id, vuln_id, product_id, status, justification, issued_at, expires_at
|
||||
FROM vex_state
|
||||
WHERE rn = 1
|
||||
ORDER BY statement_id
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
|
||||
cmd.Parameters.AddWithValue("seq", queryPoint.SequenceNumber);
|
||||
cmd.Parameters.AddWithValue("limit", request.PageSize);
|
||||
|
||||
var items = new List<VexHistoryItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
items.Add(new VexHistoryItem(
|
||||
StatementId: reader.GetString(reader.GetOrdinal("statement_id")),
|
||||
VulnId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||
ProductId: reader.GetString(reader.GetOrdinal("product_id")),
|
||||
Status: reader.GetString(reader.GetOrdinal("status")),
|
||||
Justification: reader.IsDBNull(reader.GetOrdinal("justification")) ? null : reader.GetString(reader.GetOrdinal("justification")),
|
||||
IssuedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("issued_at")),
|
||||
ExpiresAt: reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at"))));
|
||||
}
|
||||
|
||||
return new HistoricalQueryResponse<VexHistoryItem>(
|
||||
queryPoint,
|
||||
EntityType.Vex,
|
||||
items,
|
||||
null,
|
||||
items.Count);
|
||||
}
|
||||
|
||||
public async Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryAdvisoriesAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var queryPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.AtTimestamp,
|
||||
request.AtSequence,
|
||||
request.SnapshotId,
|
||||
ct);
|
||||
|
||||
if (queryPoint == null)
|
||||
{
|
||||
return new HistoricalQueryResponse<AdvisoryHistoryItem>(
|
||||
new QueryPoint(DateTimeOffset.UtcNow, 0),
|
||||
EntityType.Advisory,
|
||||
Array.Empty<AdvisoryHistoryItem>(),
|
||||
null,
|
||||
0);
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
WITH advisory_state AS (
|
||||
SELECT
|
||||
e.payload->>'advisoryId' as advisory_id,
|
||||
e.payload->>'source' as source,
|
||||
e.payload->>'title' as title,
|
||||
(e.payload->>'cvssScore')::decimal as cvss_score,
|
||||
(e.payload->>'publishedAt')::timestamptz as published_at,
|
||||
(e.payload->>'modifiedAt')::timestamptz as modified_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.payload->>'advisoryId' ORDER BY e.sequence_number DESC) as rn
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number <= @seq
|
||||
AND e.event_type LIKE 'advisory.%'
|
||||
)
|
||||
SELECT advisory_id, source, title, cvss_score, published_at, modified_at
|
||||
FROM advisory_state
|
||||
WHERE rn = 1
|
||||
ORDER BY advisory_id
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
|
||||
cmd.Parameters.AddWithValue("seq", queryPoint.SequenceNumber);
|
||||
cmd.Parameters.AddWithValue("limit", request.PageSize);
|
||||
|
||||
var items = new List<AdvisoryHistoryItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
items.Add(new AdvisoryHistoryItem(
|
||||
AdvisoryId: reader.GetString(reader.GetOrdinal("advisory_id")),
|
||||
Source: reader.GetString(reader.GetOrdinal("source")),
|
||||
Title: reader.GetString(reader.GetOrdinal("title")),
|
||||
CvssScore: reader.IsDBNull(reader.GetOrdinal("cvss_score")) ? null : reader.GetDecimal(reader.GetOrdinal("cvss_score")),
|
||||
PublishedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("published_at")),
|
||||
ModifiedAt: reader.IsDBNull(reader.GetOrdinal("modified_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("modified_at"))));
|
||||
}
|
||||
|
||||
return new HistoricalQueryResponse<AdvisoryHistoryItem>(
|
||||
queryPoint,
|
||||
EntityType.Advisory,
|
||||
items,
|
||||
null,
|
||||
items.Count);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var sql = new StringBuilder("""
|
||||
SELECT event_id, sequence_number, chain_id, chain_sequence,
|
||||
event_type, occurred_at, recorded_at,
|
||||
actor_id, actor_type, artifact_id, finding_id,
|
||||
policy_version, event_hash, previous_hash, payload
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenantId", request.TenantId)
|
||||
};
|
||||
|
||||
if (request.FromSequence.HasValue)
|
||||
{
|
||||
sql.Append(" AND sequence_number >= @fromSeq");
|
||||
parameters.Add(new NpgsqlParameter("fromSeq", request.FromSequence.Value));
|
||||
}
|
||||
|
||||
if (request.ToSequence.HasValue)
|
||||
{
|
||||
sql.Append(" AND sequence_number <= @toSeq");
|
||||
parameters.Add(new NpgsqlParameter("toSeq", request.ToSequence.Value));
|
||||
}
|
||||
|
||||
if (request.FromTimestamp.HasValue)
|
||||
{
|
||||
sql.Append(" AND recorded_at >= @fromTs");
|
||||
parameters.Add(new NpgsqlParameter("fromTs", request.FromTimestamp.Value));
|
||||
}
|
||||
|
||||
if (request.ToTimestamp.HasValue)
|
||||
{
|
||||
sql.Append(" AND recorded_at <= @toTs");
|
||||
parameters.Add(new NpgsqlParameter("toTs", request.ToTimestamp.Value));
|
||||
}
|
||||
|
||||
if (request.ChainIds?.Count > 0)
|
||||
{
|
||||
sql.Append(" AND chain_id = ANY(@chainIds)");
|
||||
parameters.Add(new NpgsqlParameter("chainIds", request.ChainIds.ToArray()));
|
||||
}
|
||||
|
||||
if (request.EventTypes?.Count > 0)
|
||||
{
|
||||
sql.Append(" AND event_type = ANY(@eventTypes)");
|
||||
parameters.Add(new NpgsqlParameter("eventTypes", request.EventTypes.ToArray()));
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY sequence_number LIMIT @limit");
|
||||
parameters.Add(new NpgsqlParameter("limit", request.PageSize + 1));
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var events = new List<ReplayEvent>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct) && events.Count < request.PageSize)
|
||||
{
|
||||
object? payload = null;
|
||||
if (request.IncludePayload && !reader.IsDBNull(reader.GetOrdinal("payload")))
|
||||
{
|
||||
var payloadJson = reader.GetString(reader.GetOrdinal("payload"));
|
||||
payload = JsonSerializer.Deserialize<object>(payloadJson, _jsonOptions);
|
||||
}
|
||||
|
||||
events.Add(new ReplayEvent(
|
||||
EventId: reader.GetGuid(reader.GetOrdinal("event_id")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
ChainId: reader.GetGuid(reader.GetOrdinal("chain_id")),
|
||||
ChainSequence: reader.GetInt32(reader.GetOrdinal("chain_sequence")),
|
||||
EventType: reader.GetString(reader.GetOrdinal("event_type")),
|
||||
OccurredAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
|
||||
RecordedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("recorded_at")),
|
||||
ActorId: reader.IsDBNull(reader.GetOrdinal("actor_id")) ? null : reader.GetString(reader.GetOrdinal("actor_id")),
|
||||
ActorType: reader.IsDBNull(reader.GetOrdinal("actor_type")) ? null : reader.GetString(reader.GetOrdinal("actor_type")),
|
||||
ArtifactId: reader.IsDBNull(reader.GetOrdinal("artifact_id")) ? null : reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||
FindingId: reader.IsDBNull(reader.GetOrdinal("finding_id")) ? null : reader.GetString(reader.GetOrdinal("finding_id")),
|
||||
PolicyVersion: reader.IsDBNull(reader.GetOrdinal("policy_version")) ? null : reader.GetString(reader.GetOrdinal("policy_version")),
|
||||
EventHash: reader.GetString(reader.GetOrdinal("event_hash")),
|
||||
PreviousHash: reader.GetString(reader.GetOrdinal("previous_hash")),
|
||||
Payload: payload));
|
||||
}
|
||||
|
||||
var hasMore = await reader.ReadAsync(ct);
|
||||
sw.Stop();
|
||||
|
||||
var fromSeq = events.Count > 0 ? events.First().SequenceNumber : 0;
|
||||
var toSeq = events.Count > 0 ? events.Last().SequenceNumber : 0;
|
||||
|
||||
var metadata = new ReplayMetadata(
|
||||
FromSequence: fromSeq,
|
||||
ToSequence: toSeq,
|
||||
EventsCount: events.Count,
|
||||
HasMore: hasMore,
|
||||
ReplayDurationMs: sw.ElapsedMilliseconds);
|
||||
|
||||
return (events, metadata);
|
||||
}
|
||||
|
||||
public async Task<DiffResponse> ComputeDiffAsync(
|
||||
DiffRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var fromPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.From.Timestamp,
|
||||
request.From.SequenceNumber,
|
||||
request.From.SnapshotId,
|
||||
ct) ?? new QueryPoint(DateTimeOffset.MinValue, 0);
|
||||
|
||||
var toPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.To.Timestamp,
|
||||
request.To.SequenceNumber,
|
||||
request.To.SnapshotId,
|
||||
ct) ?? await GetCurrentPointAsync(request.TenantId, ct);
|
||||
|
||||
// Count changes between the two points
|
||||
const string countSql = """
|
||||
WITH changes AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN e.event_type LIKE 'finding.%' THEN 'Finding'
|
||||
WHEN e.event_type LIKE 'vex.%' THEN 'Vex'
|
||||
WHEN e.event_type LIKE 'advisory.%' THEN 'Advisory'
|
||||
WHEN e.event_type LIKE 'sbom.%' THEN 'Sbom'
|
||||
ELSE 'Evidence'
|
||||
END as entity_type,
|
||||
CASE
|
||||
WHEN e.event_type LIKE '%.created' THEN 'Added'
|
||||
WHEN e.event_type LIKE '%.deleted' THEN 'Removed'
|
||||
ELSE 'Modified'
|
||||
END as change_type
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number > @fromSeq
|
||||
AND e.sequence_number <= @toSeq
|
||||
)
|
||||
SELECT entity_type, change_type, COUNT(*) as cnt
|
||||
FROM changes
|
||||
GROUP BY entity_type, change_type
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(countSql);
|
||||
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
|
||||
cmd.Parameters.AddWithValue("fromSeq", fromPoint.SequenceNumber);
|
||||
cmd.Parameters.AddWithValue("toSeq", toPoint.SequenceNumber);
|
||||
|
||||
var byEntityType = new Dictionary<EntityType, DiffCounts>();
|
||||
int totalAdded = 0, totalModified = 0, totalRemoved = 0;
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var entityTypeStr = reader.GetString(0);
|
||||
var changeType = reader.GetString(1);
|
||||
var count = (int)reader.GetInt64(2);
|
||||
|
||||
if (Enum.TryParse<EntityType>(entityTypeStr, out var entityType))
|
||||
{
|
||||
if (!byEntityType.TryGetValue(entityType, out var counts))
|
||||
{
|
||||
counts = new DiffCounts(0, 0, 0);
|
||||
}
|
||||
|
||||
byEntityType[entityType] = changeType switch
|
||||
{
|
||||
"Added" => counts with { Added = counts.Added + count },
|
||||
"Removed" => counts with { Removed = counts.Removed + count },
|
||||
_ => counts with { Modified = counts.Modified + count }
|
||||
};
|
||||
|
||||
switch (changeType)
|
||||
{
|
||||
case "Added": totalAdded += count; break;
|
||||
case "Removed": totalRemoved += count; break;
|
||||
default: totalModified += count; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var summary = new DiffSummary(
|
||||
Added: totalAdded,
|
||||
Modified: totalModified,
|
||||
Removed: totalRemoved,
|
||||
Unchanged: 0,
|
||||
ByEntityType: byEntityType.Count > 0 ? byEntityType : null);
|
||||
|
||||
// For detailed output, include individual changes
|
||||
IReadOnlyList<DiffEntry>? changes = null;
|
||||
if (request.OutputFormat != DiffOutputFormat.Summary)
|
||||
{
|
||||
changes = await GetDetailedChangesAsync(
|
||||
request.TenantId,
|
||||
fromPoint.SequenceNumber,
|
||||
toPoint.SequenceNumber,
|
||||
request.EntityTypes,
|
||||
ct);
|
||||
}
|
||||
|
||||
return new DiffResponse(
|
||||
FromPoint: fromPoint,
|
||||
ToPoint: toPoint,
|
||||
Summary: summary,
|
||||
Changes: changes,
|
||||
NextPageToken: null);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DiffEntry>> GetDetailedChangesAsync(
|
||||
string tenantId,
|
||||
long fromSeq,
|
||||
long toSeq,
|
||||
IReadOnlyList<EntityType>? entityTypes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sql = new StringBuilder("""
|
||||
SELECT
|
||||
e.event_type,
|
||||
COALESCE(e.finding_id, e.artifact_id, e.payload->>'entityId') as entity_id,
|
||||
e.payload as to_state
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number > @fromSeq
|
||||
AND e.sequence_number <= @toSeq
|
||||
""");
|
||||
|
||||
if (entityTypes?.Count > 0)
|
||||
{
|
||||
var patterns = entityTypes.Select(et => et switch
|
||||
{
|
||||
EntityType.Finding => "finding.%",
|
||||
EntityType.Vex => "vex.%",
|
||||
EntityType.Advisory => "advisory.%",
|
||||
EntityType.Sbom => "sbom.%",
|
||||
_ => "evidence.%"
|
||||
}).ToList();
|
||||
|
||||
sql.Append(" AND (");
|
||||
for (int i = 0; i < patterns.Count; i++)
|
||||
{
|
||||
if (i > 0) sql.Append(" OR ");
|
||||
sql.Append($"e.event_type LIKE @pattern{i}");
|
||||
}
|
||||
sql.Append(")");
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY e.sequence_number LIMIT 1000");
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("fromSeq", fromSeq);
|
||||
cmd.Parameters.AddWithValue("toSeq", toSeq);
|
||||
|
||||
if (entityTypes?.Count > 0)
|
||||
{
|
||||
var patterns = entityTypes.Select(et => et switch
|
||||
{
|
||||
EntityType.Finding => "finding.%",
|
||||
EntityType.Vex => "vex.%",
|
||||
EntityType.Advisory => "advisory.%",
|
||||
EntityType.Sbom => "sbom.%",
|
||||
_ => "evidence.%"
|
||||
}).ToList();
|
||||
|
||||
for (int i = 0; i < patterns.Count; i++)
|
||||
{
|
||||
cmd.Parameters.AddWithValue($"pattern{i}", patterns[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var entries = new List<DiffEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var eventType = reader.GetString(0);
|
||||
var entityId = reader.IsDBNull(1) ? "unknown" : reader.GetString(1);
|
||||
var toStateJson = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
|
||||
var entityType = eventType switch
|
||||
{
|
||||
var et when et.StartsWith("finding.") => EntityType.Finding,
|
||||
var et when et.StartsWith("vex.") => EntityType.Vex,
|
||||
var et when et.StartsWith("advisory.") => EntityType.Advisory,
|
||||
var et when et.StartsWith("sbom.") => EntityType.Sbom,
|
||||
_ => EntityType.Evidence
|
||||
};
|
||||
|
||||
var changeType = eventType switch
|
||||
{
|
||||
var et when et.EndsWith(".created") => DiffChangeType.Added,
|
||||
var et when et.EndsWith(".deleted") => DiffChangeType.Removed,
|
||||
_ => DiffChangeType.Modified
|
||||
};
|
||||
|
||||
object? toState = null;
|
||||
if (!string.IsNullOrEmpty(toStateJson))
|
||||
{
|
||||
toState = JsonSerializer.Deserialize<object>(toStateJson, _jsonOptions);
|
||||
}
|
||||
|
||||
entries.Add(new DiffEntry(
|
||||
EntityType: entityType,
|
||||
EntityId: entityId,
|
||||
ChangeType: changeType,
|
||||
FromState: null,
|
||||
ToState: toState,
|
||||
ChangedFields: null));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(
|
||||
string tenantId,
|
||||
EntityType entityType,
|
||||
string entityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var eventTypePrefix = entityType switch
|
||||
{
|
||||
EntityType.Finding => "finding.",
|
||||
EntityType.Vex => "vex.",
|
||||
EntityType.Advisory => "advisory.",
|
||||
EntityType.Sbom => "sbom.",
|
||||
_ => "evidence."
|
||||
};
|
||||
|
||||
const string sql = """
|
||||
SELECT sequence_number, recorded_at, event_type, event_hash, actor_id,
|
||||
COALESCE(payload->>'summary', event_type) as summary
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
AND event_type LIKE @eventTypePrefix
|
||||
AND (finding_id = @entityId OR artifact_id = @entityId OR payload->>'entityId' = @entityId)
|
||||
ORDER BY sequence_number DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("eventTypePrefix", eventTypePrefix + "%");
|
||||
cmd.Parameters.AddWithValue("entityId", entityId);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
var entries = new List<ChangeLogEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
entries.Add(new ChangeLogEntry(
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("recorded_at")),
|
||||
EntityType: entityType,
|
||||
EntityId: entityId,
|
||||
EventType: reader.GetString(reader.GetOrdinal("event_type")),
|
||||
EventHash: reader.IsDBNull(reader.GetOrdinal("event_hash")) ? null : reader.GetString(reader.GetOrdinal("event_hash")),
|
||||
ActorId: reader.IsDBNull(reader.GetOrdinal("actor_id")) ? null : reader.GetString(reader.GetOrdinal("actor_id")),
|
||||
Summary: reader.IsDBNull(reader.GetOrdinal("summary")) ? null : reader.GetString(reader.GetOrdinal("summary"))));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async Task<StalenessResult> CheckStalenessAsync(
|
||||
string tenantId,
|
||||
TimeSpan threshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var checkedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
MAX(recorded_at) as last_event,
|
||||
MAX(CASE WHEN event_type LIKE 'finding.%' THEN recorded_at END) as finding_last,
|
||||
MAX(CASE WHEN event_type LIKE 'vex.%' THEN recorded_at END) as vex_last,
|
||||
MAX(CASE WHEN event_type LIKE 'advisory.%' THEN recorded_at END) as advisory_last
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
|
||||
var lastEventAt = reader.IsDBNull(0) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(0);
|
||||
var findingLast = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(1);
|
||||
var vexLast = reader.IsDBNull(2) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(2);
|
||||
var advisoryLast = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3);
|
||||
|
||||
var isStale = lastEventAt.HasValue && (checkedAt - lastEventAt.Value) > threshold;
|
||||
var stalenessDuration = lastEventAt.HasValue ? checkedAt - lastEventAt.Value : (TimeSpan?)null;
|
||||
|
||||
var byEntityType = new Dictionary<EntityType, EntityStaleness>
|
||||
{
|
||||
[EntityType.Finding] = new EntityStaleness(
|
||||
findingLast.HasValue && (checkedAt - findingLast.Value) > threshold,
|
||||
findingLast,
|
||||
0),
|
||||
[EntityType.Vex] = new EntityStaleness(
|
||||
vexLast.HasValue && (checkedAt - vexLast.Value) > threshold,
|
||||
vexLast,
|
||||
0),
|
||||
[EntityType.Advisory] = new EntityStaleness(
|
||||
advisoryLast.HasValue && (checkedAt - advisoryLast.Value) > threshold,
|
||||
advisoryLast,
|
||||
0)
|
||||
};
|
||||
|
||||
return new StalenessResult(
|
||||
IsStale: isStale,
|
||||
CheckedAt: checkedAt,
|
||||
LastEventAt: lastEventAt,
|
||||
StalenessThreshold: threshold,
|
||||
StalenessDuration: stalenessDuration,
|
||||
ByEntityType: byEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for ledger snapshot persistence.
|
||||
/// </summary>
|
||||
public interface ISnapshotRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new snapshot record.
|
||||
/// </summary>
|
||||
Task<LedgerSnapshot> CreateAsync(
|
||||
string tenantId,
|
||||
CreateSnapshotInput input,
|
||||
long currentSequence,
|
||||
DateTimeOffset currentTimestamp,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot by ID.
|
||||
/// </summary>
|
||||
Task<LedgerSnapshot?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists snapshots with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates snapshot status.
|
||||
/// </summary>
|
||||
Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatus newStatus,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates snapshot statistics.
|
||||
/// </summary>
|
||||
Task<bool> UpdateStatisticsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatistics statistics,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Merkle root and optional DSSE digest for a snapshot.
|
||||
/// </summary>
|
||||
Task<bool> SetMerkleRootAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
string merkleRoot,
|
||||
string? dsseDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks expired snapshots as expired.
|
||||
/// </summary>
|
||||
Task<int> ExpireSnapshotsAsync(
|
||||
DateTimeOffset cutoff,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a snapshot (soft delete - marks as Deleted).
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest snapshot for a tenant.
|
||||
/// </summary>
|
||||
Task<LedgerSnapshot?> GetLatestAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a snapshot exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for time-travel queries.
|
||||
/// </summary>
|
||||
public interface ITimeTravelRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current sequence number and timestamp.
|
||||
/// </summary>
|
||||
Task<QueryPoint> GetCurrentPointAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a query point from timestamp, sequence, or snapshot ID.
|
||||
/// </summary>
|
||||
Task<QueryPoint?> ResolveQueryPointAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? timestamp,
|
||||
long? sequence,
|
||||
Guid? snapshotId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical findings at a specific point.
|
||||
/// </summary>
|
||||
Task<HistoricalQueryResponse<FindingHistoryItem>> QueryFindingsAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical VEX statements at a specific point.
|
||||
/// </summary>
|
||||
Task<HistoricalQueryResponse<VexHistoryItem>> QueryVexAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical advisories at a specific point.
|
||||
/// </summary>
|
||||
Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryAdvisoriesAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Replays events within a range.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes diff between two points.
|
||||
/// </summary>
|
||||
Task<DiffResponse> ComputeDiffAsync(
|
||||
DiffRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets changelog entries for an entity.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(
|
||||
string tenantId,
|
||||
EntityType entityType,
|
||||
string entityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks staleness of ledger data.
|
||||
/// </summary>
|
||||
Task<StalenessResult> CheckStalenessAsync(
|
||||
string tenantId,
|
||||
TimeSpan threshold,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historical finding item.
|
||||
/// </summary>
|
||||
public sealed record FindingHistoryItem(
|
||||
string FindingId,
|
||||
string ArtifactId,
|
||||
string VulnId,
|
||||
string Status,
|
||||
decimal? Severity,
|
||||
string? PolicyVersion,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastUpdated,
|
||||
Dictionary<string, string>? Labels);
|
||||
|
||||
/// <summary>
|
||||
/// Historical VEX item.
|
||||
/// </summary>
|
||||
public sealed record VexHistoryItem(
|
||||
string StatementId,
|
||||
string VulnId,
|
||||
string ProductId,
|
||||
string Status,
|
||||
string? Justification,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Historical advisory item.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryHistoryItem(
|
||||
string AdvisoryId,
|
||||
string Source,
|
||||
string Title,
|
||||
decimal? CvssScore,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset? ModifiedAt);
|
||||
@@ -17,6 +17,12 @@ internal static class LedgerTimeline
|
||||
private static readonly EventId AirgapImport = new(6401, "ledger.airgap.imported");
|
||||
private static readonly EventId EvidenceSnapshotLinkedEvent = new(6501, "ledger.evidence.snapshot_linked");
|
||||
private static readonly EventId AirgapTimelineImpactEvent = new(6601, "ledger.airgap.timeline_impact");
|
||||
private static readonly EventId AttestationPointerLinkedEvent = new(6701, "ledger.attestation.pointer_linked");
|
||||
private static readonly EventId SnapshotCreatedEvent = new(6801, "ledger.snapshot.created");
|
||||
private static readonly EventId SnapshotDeletedEvent = new(6802, "ledger.snapshot.deleted");
|
||||
private static readonly EventId TimeTravelQueryEvent = new(6803, "ledger.timetravel.query");
|
||||
private static readonly EventId ReplayCompletedEvent = new(6804, "ledger.replay.completed");
|
||||
private static readonly EventId DiffComputedEvent = new(6805, "ledger.diff.computed");
|
||||
|
||||
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
|
||||
{
|
||||
@@ -144,4 +150,134 @@ internal static class LedgerTimeline
|
||||
timeAnchor.ToString("O"),
|
||||
sealedMode);
|
||||
}
|
||||
|
||||
public static void EmitAttestationPointerLinked(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
string findingId,
|
||||
Guid pointerId,
|
||||
string attestationType,
|
||||
string digest)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
AttestationPointerLinkedEvent,
|
||||
"timeline ledger.attestation.pointer_linked tenant={Tenant} finding={FindingId} pointer={PointerId} attestation_type={AttestationType} digest={Digest}",
|
||||
tenantId,
|
||||
findingId,
|
||||
pointerId,
|
||||
attestationType,
|
||||
digest);
|
||||
}
|
||||
|
||||
public static void EmitSnapshotCreated(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
long sequenceNumber,
|
||||
long findingsCount)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
SnapshotCreatedEvent,
|
||||
"timeline ledger.snapshot.created tenant={Tenant} snapshot={SnapshotId} sequence={SequenceNumber} findings_count={FindingsCount}",
|
||||
tenantId,
|
||||
snapshotId,
|
||||
sequenceNumber,
|
||||
findingsCount);
|
||||
}
|
||||
|
||||
public static void EmitSnapshotDeleted(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
Guid snapshotId)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
SnapshotDeletedEvent,
|
||||
"timeline ledger.snapshot.deleted tenant={Tenant} snapshot={SnapshotId}",
|
||||
tenantId,
|
||||
snapshotId);
|
||||
}
|
||||
|
||||
public static void EmitTimeTravelQuery(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
string entityType,
|
||||
long atSequence,
|
||||
int resultCount)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
TimeTravelQueryEvent,
|
||||
"timeline ledger.timetravel.query tenant={Tenant} entity_type={EntityType} at_sequence={AtSequence} result_count={ResultCount}",
|
||||
tenantId,
|
||||
entityType,
|
||||
atSequence,
|
||||
resultCount);
|
||||
}
|
||||
|
||||
public static void EmitReplayCompleted(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
long fromSequence,
|
||||
long toSequence,
|
||||
int eventsCount,
|
||||
long durationMs)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
ReplayCompletedEvent,
|
||||
"timeline ledger.replay.completed tenant={Tenant} from_sequence={FromSequence} to_sequence={ToSequence} events_count={EventsCount} duration_ms={DurationMs}",
|
||||
tenantId,
|
||||
fromSequence,
|
||||
toSequence,
|
||||
eventsCount,
|
||||
durationMs);
|
||||
}
|
||||
|
||||
public static void EmitDiffComputed(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
long fromSequence,
|
||||
long toSequence,
|
||||
int added,
|
||||
int modified,
|
||||
int removed)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
DiffComputedEvent,
|
||||
"timeline ledger.diff.computed tenant={Tenant} from_sequence={FromSequence} to_sequence={ToSequence} added={Added} modified={Modified} removed={Removed}",
|
||||
tenantId,
|
||||
fromSequence,
|
||||
toSequence,
|
||||
added,
|
||||
modified,
|
||||
removed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing attestation pointers linking findings to verification reports and attestation envelopes.
|
||||
/// </summary>
|
||||
public sealed class AttestationPointerService
|
||||
{
|
||||
private readonly ILedgerEventRepository _ledgerEventRepository;
|
||||
private readonly ILedgerEventWriteService _writeService;
|
||||
private readonly IAttestationPointerRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AttestationPointerService> _logger;
|
||||
|
||||
public AttestationPointerService(
|
||||
ILedgerEventRepository ledgerEventRepository,
|
||||
ILedgerEventWriteService writeService,
|
||||
IAttestationPointerRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AttestationPointerService> logger)
|
||||
{
|
||||
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
|
||||
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation pointer linking a finding to a verification report or attestation envelope.
|
||||
/// </summary>
|
||||
public async Task<AttestationPointerResult> CreatePointerAsync(
|
||||
AttestationPointerInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.FindingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.AttestationRef.Digest);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var createdBy = input.CreatedBy ?? "attestation-linker";
|
||||
|
||||
// Check for idempotency
|
||||
var exists = await _repository.ExistsAsync(
|
||||
input.TenantId,
|
||||
input.FindingId,
|
||||
input.AttestationRef.Digest,
|
||||
input.AttestationType,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Attestation pointer already exists for finding {FindingId} with digest {Digest}",
|
||||
input.FindingId, input.AttestationRef.Digest);
|
||||
|
||||
// Find and return the existing pointer
|
||||
var existing = await _repository.GetByDigestAsync(
|
||||
input.TenantId,
|
||||
input.AttestationRef.Digest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var match = existing.FirstOrDefault(p =>
|
||||
p.FindingId == input.FindingId && p.AttestationType == input.AttestationType);
|
||||
|
||||
return new AttestationPointerResult(true, match?.PointerId, match?.LedgerEventId, null);
|
||||
}
|
||||
|
||||
var pointerId = Guid.NewGuid();
|
||||
|
||||
// Create ledger event for the attestation pointer
|
||||
var chainId = LedgerChainIdGenerator.FromTenantSubject(
|
||||
input.TenantId, $"attestation::{input.FindingId}");
|
||||
|
||||
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(
|
||||
input.TenantId, chainId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
|
||||
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
var attestationPayload = BuildAttestationPayload(input, pointerId);
|
||||
var envelope = BuildEnvelope(eventId, input, chainId, sequence, now, attestationPayload);
|
||||
|
||||
var draft = new LedgerEventDraft(
|
||||
input.TenantId,
|
||||
chainId,
|
||||
sequence,
|
||||
eventId,
|
||||
LedgerEventConstants.EventAttestationPointerLinked,
|
||||
"attestation-pointer",
|
||||
input.FindingId,
|
||||
input.FindingId,
|
||||
SourceRunId: null,
|
||||
ActorId: createdBy,
|
||||
ActorType: "system",
|
||||
OccurredAt: now,
|
||||
RecordedAt: now,
|
||||
Payload: attestationPayload,
|
||||
CanonicalEnvelope: envelope,
|
||||
ProvidedPreviousHash: previousHash);
|
||||
|
||||
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
|
||||
{
|
||||
var error = string.Join(";", writeResult.Errors);
|
||||
_logger.LogWarning(
|
||||
"Failed to write ledger event for attestation pointer {PointerId}: {Error}",
|
||||
pointerId, error);
|
||||
return new AttestationPointerResult(false, null, null, error);
|
||||
}
|
||||
|
||||
var ledgerEventId = writeResult.Record?.EventId;
|
||||
|
||||
var record = new AttestationPointerRecord(
|
||||
input.TenantId,
|
||||
pointerId,
|
||||
input.FindingId,
|
||||
input.AttestationType,
|
||||
input.Relationship,
|
||||
input.AttestationRef,
|
||||
input.VerificationResult,
|
||||
now,
|
||||
createdBy,
|
||||
input.Metadata,
|
||||
ledgerEventId);
|
||||
|
||||
await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
LedgerTimeline.EmitAttestationPointerLinked(
|
||||
_logger,
|
||||
input.TenantId,
|
||||
input.FindingId,
|
||||
pointerId,
|
||||
input.AttestationType.ToString(),
|
||||
input.AttestationRef.Digest);
|
||||
|
||||
return new AttestationPointerResult(true, pointerId, ledgerEventId, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation pointers for a finding.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetPointersAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
return await _repository.GetByFindingIdAsync(tenantId, findingId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attestation pointer by ID.
|
||||
/// </summary>
|
||||
public async Task<AttestationPointerRecord?> GetPointerAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches attestation pointers.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(
|
||||
AttestationPointerQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
return await _repository.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summary for a finding.
|
||||
/// </summary>
|
||||
public async Task<FindingAttestationSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
return await _repository.GetSummaryAsync(tenantId, findingId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summaries for multiple findings.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(findingIds);
|
||||
|
||||
return await _repository.GetSummariesAsync(tenantId, findingIds, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the verification result for an attestation pointer.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdateVerificationResultAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
VerificationResult verificationResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(verificationResult);
|
||||
|
||||
var existing = await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation pointer {PointerId} not found for tenant {TenantId}",
|
||||
pointerId, tenantId);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _repository.UpdateVerificationResultAsync(
|
||||
tenantId, pointerId, verificationResult, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated verification result for attestation pointer {PointerId}, verified={Verified}",
|
||||
pointerId, verificationResult.Verified);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets findings that have attestations matching the criteria.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
|
||||
string tenantId,
|
||||
AttestationVerificationFilter? verificationFilter = null,
|
||||
IReadOnlyList<AttestationType>? attestationTypes = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return await _repository.GetFindingIdsWithAttestationsAsync(
|
||||
tenantId, verificationFilter, attestationTypes, limit, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static JsonObject BuildAttestationPayload(AttestationPointerInput input, Guid pointerId)
|
||||
{
|
||||
var attestationRefNode = new JsonObject
|
||||
{
|
||||
["digest"] = input.AttestationRef.Digest
|
||||
};
|
||||
|
||||
if (input.AttestationRef.AttestationId.HasValue)
|
||||
{
|
||||
attestationRefNode["attestation_id"] = input.AttestationRef.AttestationId.Value.ToString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.StorageUri))
|
||||
{
|
||||
attestationRefNode["storage_uri"] = input.AttestationRef.StorageUri;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.PayloadType))
|
||||
{
|
||||
attestationRefNode["payload_type"] = input.AttestationRef.PayloadType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.PredicateType))
|
||||
{
|
||||
attestationRefNode["predicate_type"] = input.AttestationRef.PredicateType;
|
||||
}
|
||||
|
||||
if (input.AttestationRef.SubjectDigests is { Count: > 0 })
|
||||
{
|
||||
var subjectsArray = new JsonArray();
|
||||
foreach (var subject in input.AttestationRef.SubjectDigests)
|
||||
{
|
||||
subjectsArray.Add(subject);
|
||||
}
|
||||
attestationRefNode["subject_digests"] = subjectsArray;
|
||||
}
|
||||
|
||||
if (input.AttestationRef.SignerInfo is not null)
|
||||
{
|
||||
var signerNode = new JsonObject();
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.KeyId))
|
||||
{
|
||||
signerNode["key_id"] = input.AttestationRef.SignerInfo.KeyId;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Issuer))
|
||||
{
|
||||
signerNode["issuer"] = input.AttestationRef.SignerInfo.Issuer;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Subject))
|
||||
{
|
||||
signerNode["subject"] = input.AttestationRef.SignerInfo.Subject;
|
||||
}
|
||||
if (input.AttestationRef.SignerInfo.SignedAt.HasValue)
|
||||
{
|
||||
signerNode["signed_at"] = FormatTimestamp(input.AttestationRef.SignerInfo.SignedAt.Value);
|
||||
}
|
||||
attestationRefNode["signer_info"] = signerNode;
|
||||
}
|
||||
|
||||
if (input.AttestationRef.RekorEntry is not null)
|
||||
{
|
||||
var rekorNode = new JsonObject();
|
||||
if (input.AttestationRef.RekorEntry.LogIndex.HasValue)
|
||||
{
|
||||
rekorNode["log_index"] = input.AttestationRef.RekorEntry.LogIndex.Value;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.LogId))
|
||||
{
|
||||
rekorNode["log_id"] = input.AttestationRef.RekorEntry.LogId;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.Uuid))
|
||||
{
|
||||
rekorNode["uuid"] = input.AttestationRef.RekorEntry.Uuid;
|
||||
}
|
||||
if (input.AttestationRef.RekorEntry.IntegratedTime.HasValue)
|
||||
{
|
||||
rekorNode["integrated_time"] = input.AttestationRef.RekorEntry.IntegratedTime.Value;
|
||||
}
|
||||
attestationRefNode["rekor_entry"] = rekorNode;
|
||||
}
|
||||
|
||||
var pointerNode = new JsonObject
|
||||
{
|
||||
["pointer_id"] = pointerId.ToString(),
|
||||
["attestation_type"] = input.AttestationType.ToString(),
|
||||
["relationship"] = input.Relationship.ToString(),
|
||||
["attestation_ref"] = attestationRefNode
|
||||
};
|
||||
|
||||
if (input.VerificationResult is not null)
|
||||
{
|
||||
var verificationNode = new JsonObject
|
||||
{
|
||||
["verified"] = input.VerificationResult.Verified,
|
||||
["verified_at"] = FormatTimestamp(input.VerificationResult.VerifiedAt)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(input.VerificationResult.Verifier))
|
||||
{
|
||||
verificationNode["verifier"] = input.VerificationResult.Verifier;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.VerificationResult.VerifierVersion))
|
||||
{
|
||||
verificationNode["verifier_version"] = input.VerificationResult.VerifierVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.VerificationResult.PolicyRef))
|
||||
{
|
||||
verificationNode["policy_ref"] = input.VerificationResult.PolicyRef;
|
||||
}
|
||||
|
||||
if (input.VerificationResult.Checks is { Count: > 0 })
|
||||
{
|
||||
var checksArray = new JsonArray();
|
||||
foreach (var check in input.VerificationResult.Checks)
|
||||
{
|
||||
var checkNode = new JsonObject
|
||||
{
|
||||
["check_type"] = check.CheckType.ToString(),
|
||||
["passed"] = check.Passed
|
||||
};
|
||||
if (!string.IsNullOrEmpty(check.Details))
|
||||
{
|
||||
checkNode["details"] = check.Details;
|
||||
}
|
||||
checksArray.Add(checkNode);
|
||||
}
|
||||
verificationNode["checks"] = checksArray;
|
||||
}
|
||||
|
||||
if (input.VerificationResult.Warnings is { Count: > 0 })
|
||||
{
|
||||
var warningsArray = new JsonArray();
|
||||
foreach (var warning in input.VerificationResult.Warnings)
|
||||
{
|
||||
warningsArray.Add(warning);
|
||||
}
|
||||
verificationNode["warnings"] = warningsArray;
|
||||
}
|
||||
|
||||
if (input.VerificationResult.Errors is { Count: > 0 })
|
||||
{
|
||||
var errorsArray = new JsonArray();
|
||||
foreach (var error in input.VerificationResult.Errors)
|
||||
{
|
||||
errorsArray.Add(error);
|
||||
}
|
||||
verificationNode["errors"] = errorsArray;
|
||||
}
|
||||
|
||||
pointerNode["verification_result"] = verificationNode;
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["attestation"] = new JsonObject
|
||||
{
|
||||
["pointer"] = pointerNode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildEnvelope(
|
||||
Guid eventId,
|
||||
AttestationPointerInput input,
|
||||
Guid chainId,
|
||||
long sequence,
|
||||
DateTimeOffset now,
|
||||
JsonObject payload)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["event"] = new JsonObject
|
||||
{
|
||||
["id"] = eventId.ToString(),
|
||||
["type"] = LedgerEventConstants.EventAttestationPointerLinked,
|
||||
["tenant"] = input.TenantId,
|
||||
["chainId"] = chainId.ToString(),
|
||||
["sequence"] = sequence,
|
||||
["policyVersion"] = "attestation-pointer",
|
||||
["artifactId"] = input.FindingId,
|
||||
["finding"] = new JsonObject
|
||||
{
|
||||
["id"] = input.FindingId,
|
||||
["artifactId"] = input.FindingId,
|
||||
["vulnId"] = "attestation-pointer"
|
||||
},
|
||||
["actor"] = new JsonObject
|
||||
{
|
||||
["id"] = input.CreatedBy ?? "attestation-linker",
|
||||
["type"] = "system"
|
||||
},
|
||||
["occurredAt"] = FormatTimestamp(now),
|
||||
["recordedAt"] = FormatTimestamp(now),
|
||||
["payload"] = payload.DeepClone()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing ledger snapshots and time-travel queries.
|
||||
/// </summary>
|
||||
public sealed class SnapshotService
|
||||
{
|
||||
private readonly ISnapshotRepository _snapshotRepository;
|
||||
private readonly ITimeTravelRepository _timeTravelRepository;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public SnapshotService(
|
||||
ISnapshotRepository snapshotRepository,
|
||||
ITimeTravelRepository timeTravelRepository,
|
||||
ILogger<SnapshotService> logger)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository;
|
||||
_timeTravelRepository = timeTravelRepository;
|
||||
_logger = logger;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new snapshot of the ledger at the specified point.
|
||||
/// </summary>
|
||||
public async Task<CreateSnapshotResult> CreateSnapshotAsync(
|
||||
CreateSnapshotInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating snapshot for tenant {TenantId} at sequence {Sequence} / timestamp {Timestamp}",
|
||||
input.TenantId,
|
||||
input.AtSequence,
|
||||
input.AtTimestamp);
|
||||
|
||||
// Get current ledger state
|
||||
var currentPoint = await _timeTravelRepository.GetCurrentPointAsync(input.TenantId, ct);
|
||||
|
||||
// Create the snapshot record
|
||||
var snapshot = await _snapshotRepository.CreateAsync(
|
||||
input.TenantId,
|
||||
input,
|
||||
currentPoint.SequenceNumber,
|
||||
currentPoint.Timestamp,
|
||||
ct);
|
||||
|
||||
// Compute statistics asynchronously
|
||||
var statistics = await ComputeStatisticsAsync(
|
||||
input.TenantId,
|
||||
snapshot.SequenceNumber,
|
||||
input.IncludeEntityTypes,
|
||||
ct);
|
||||
|
||||
await _snapshotRepository.UpdateStatisticsAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
statistics,
|
||||
ct);
|
||||
|
||||
// Compute Merkle root if signing is requested
|
||||
string? merkleRoot = null;
|
||||
string? dsseDigest = null;
|
||||
|
||||
if (input.Sign)
|
||||
{
|
||||
merkleRoot = await ComputeMerkleRootAsync(
|
||||
input.TenantId,
|
||||
snapshot.SequenceNumber,
|
||||
ct);
|
||||
|
||||
await _snapshotRepository.SetMerkleRootAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
merkleRoot,
|
||||
dsseDigest,
|
||||
ct);
|
||||
}
|
||||
|
||||
// Mark as available
|
||||
await _snapshotRepository.UpdateStatusAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
SnapshotStatus.Available,
|
||||
ct);
|
||||
|
||||
// Retrieve updated snapshot
|
||||
var finalSnapshot = await _snapshotRepository.GetByIdAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
ct);
|
||||
|
||||
LedgerTimeline.EmitSnapshotCreated(
|
||||
_logger,
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
snapshot.SequenceNumber,
|
||||
statistics.FindingsCount);
|
||||
|
||||
return new CreateSnapshotResult(true, finalSnapshot, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create snapshot for tenant {TenantId}", input.TenantId);
|
||||
return new CreateSnapshotResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot by ID.
|
||||
/// </summary>
|
||||
public async Task<LedgerSnapshot?> GetSnapshotAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _snapshotRepository.GetByIdAsync(tenantId, snapshotId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists snapshots for a tenant.
|
||||
/// </summary>
|
||||
public async Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListSnapshotsAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _snapshotRepository.ListAsync(query, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a snapshot.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteSnapshotAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var deleted = await _snapshotRepository.DeleteAsync(tenantId, snapshotId, ct);
|
||||
|
||||
if (deleted)
|
||||
{
|
||||
LedgerTimeline.EmitSnapshotDeleted(_logger, tenantId, snapshotId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical findings at a specific point in time.
|
||||
/// </summary>
|
||||
public async Task<HistoricalQueryResponse<FindingHistoryItem>> QueryHistoricalFindingsAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.QueryFindingsAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical VEX statements at a specific point in time.
|
||||
/// </summary>
|
||||
public async Task<HistoricalQueryResponse<VexHistoryItem>> QueryHistoricalVexAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.QueryVexAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical advisories at a specific point in time.
|
||||
/// </summary>
|
||||
public async Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryHistoricalAdvisoriesAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.QueryAdvisoriesAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays events within a specified range.
|
||||
/// </summary>
|
||||
public async Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.ReplayEventsAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes diff between two points in time.
|
||||
/// </summary>
|
||||
public async Task<DiffResponse> ComputeDiffAsync(
|
||||
DiffRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.ComputeDiffAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets changelog for an entity.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(
|
||||
string tenantId,
|
||||
EntityType entityType,
|
||||
string entityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.GetChangelogAsync(tenantId, entityType, entityId, limit, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks staleness of ledger data.
|
||||
/// </summary>
|
||||
public async Task<StalenessResult> CheckStalenessAsync(
|
||||
string tenantId,
|
||||
TimeSpan threshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.CheckStalenessAsync(tenantId, threshold, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current query point (latest sequence and timestamp).
|
||||
/// </summary>
|
||||
public async Task<QueryPoint> GetCurrentPointAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.GetCurrentPointAsync(tenantId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expires old snapshots.
|
||||
/// </summary>
|
||||
public async Task<int> ExpireOldSnapshotsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow;
|
||||
var count = await _snapshotRepository.ExpireSnapshotsAsync(cutoff, ct);
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
_logger.LogInformation("Expired {Count} snapshots", count);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async Task<SnapshotStatistics> ComputeStatisticsAsync(
|
||||
string tenantId,
|
||||
long atSequence,
|
||||
IReadOnlyList<EntityType>? entityTypes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Query counts from time-travel repository
|
||||
var findingsResult = await _timeTravelRepository.QueryFindingsAsync(
|
||||
new HistoricalQueryRequest(
|
||||
tenantId,
|
||||
null,
|
||||
atSequence,
|
||||
null,
|
||||
EntityType.Finding,
|
||||
null,
|
||||
1),
|
||||
ct);
|
||||
|
||||
var vexResult = await _timeTravelRepository.QueryVexAsync(
|
||||
new HistoricalQueryRequest(
|
||||
tenantId,
|
||||
null,
|
||||
atSequence,
|
||||
null,
|
||||
EntityType.Vex,
|
||||
null,
|
||||
1),
|
||||
ct);
|
||||
|
||||
var advisoryResult = await _timeTravelRepository.QueryAdvisoriesAsync(
|
||||
new HistoricalQueryRequest(
|
||||
tenantId,
|
||||
null,
|
||||
atSequence,
|
||||
null,
|
||||
EntityType.Advisory,
|
||||
null,
|
||||
1),
|
||||
ct);
|
||||
|
||||
// Get event count
|
||||
var (events, _) = await _timeTravelRepository.ReplayEventsAsync(
|
||||
new ReplayRequest(tenantId, ToSequence: atSequence, IncludePayload: false, PageSize: 1),
|
||||
ct);
|
||||
|
||||
// Note: These are approximations; actual counting would need dedicated queries
|
||||
return new SnapshotStatistics(
|
||||
FindingsCount: findingsResult.TotalCount,
|
||||
VexStatementsCount: vexResult.TotalCount,
|
||||
AdvisoriesCount: advisoryResult.TotalCount,
|
||||
SbomsCount: 0, // Would need separate SBOM tracking
|
||||
EventsCount: atSequence,
|
||||
SizeBytes: 0); // Would need to compute actual storage size
|
||||
}
|
||||
|
||||
private async Task<string> ComputeMerkleRootAsync(
|
||||
string tenantId,
|
||||
long atSequence,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Get all event hashes up to the sequence
|
||||
var (events, _) = await _timeTravelRepository.ReplayEventsAsync(
|
||||
new ReplayRequest(
|
||||
tenantId,
|
||||
ToSequence: atSequence,
|
||||
IncludePayload: false,
|
||||
PageSize: 10000),
|
||||
ct);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return ComputeHash("empty");
|
||||
}
|
||||
|
||||
// Build Merkle tree from event hashes
|
||||
var hashes = events.Select(e => e.EventHash).ToList();
|
||||
return ComputeMerkleRoot(hashes);
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(List<string> hashes)
|
||||
{
|
||||
if (hashes.Count == 0)
|
||||
return ComputeHash("empty");
|
||||
|
||||
if (hashes.Count == 1)
|
||||
return hashes[0];
|
||||
|
||||
var nextLevel = new List<string>();
|
||||
for (int i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < hashes.Count)
|
||||
{
|
||||
nextLevel.Add(ComputeHash(hashes[i] + hashes[i + 1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
nextLevel.Add(hashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return ComputeMerkleRoot(nextLevel);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
-- 008_attestation_pointers.sql
|
||||
-- LEDGER-ATTEST-73-001: Persist pointers from findings to verification reports and attestation envelopes
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
-- 1. Create attestation pointers table
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_attestation_pointers (
|
||||
tenant_id text NOT NULL,
|
||||
pointer_id uuid NOT NULL,
|
||||
finding_id text NOT NULL,
|
||||
attestation_type text NOT NULL,
|
||||
relationship text NOT NULL,
|
||||
attestation_ref jsonb NOT NULL,
|
||||
verification_result jsonb NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
metadata jsonb NULL,
|
||||
ledger_event_id uuid NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ledger_attestation_pointers
|
||||
ADD CONSTRAINT pk_ledger_attestation_pointers PRIMARY KEY (tenant_id, pointer_id);
|
||||
|
||||
-- ============================================
|
||||
-- 2. Create indexes for efficient queries
|
||||
-- ============================================
|
||||
|
||||
-- Index for finding lookups (most common query pattern)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_finding
|
||||
ON ledger_attestation_pointers (tenant_id, finding_id, created_at DESC);
|
||||
|
||||
-- Index for digest-based lookups (idempotency checks)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_digest
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest'));
|
||||
|
||||
-- Index for attestation type filtering
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_type
|
||||
ON ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC);
|
||||
|
||||
-- Index for verification status filtering (verified/unverified/failed)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_verified
|
||||
ON ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean))
|
||||
WHERE verification_result IS NOT NULL;
|
||||
|
||||
-- Index for signer identity searches
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_signer
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject'))
|
||||
WHERE attestation_ref->'signer_info' IS NOT NULL;
|
||||
|
||||
-- Index for predicate type searches
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_predicate
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type'))
|
||||
WHERE attestation_ref->>'predicate_type' IS NOT NULL;
|
||||
|
||||
-- ============================================
|
||||
-- 3. Enable Row-Level Security
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE ledger_attestation_pointers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_attestation_pointers FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON ledger_attestation_pointers;
|
||||
CREATE POLICY ledger_attestation_pointers_tenant_isolation
|
||||
ON ledger_attestation_pointers
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
|
||||
-- ============================================
|
||||
-- 4. Add comments for documentation
|
||||
-- ============================================
|
||||
|
||||
COMMENT ON TABLE ledger_attestation_pointers IS
|
||||
'Links findings to verification reports and attestation envelopes for explainability (LEDGER-ATTEST-73-001)';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.pointer_id IS
|
||||
'Unique identifier for this attestation pointer';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.finding_id IS
|
||||
'Finding that this pointer references';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.attestation_type IS
|
||||
'Type of attestation: verification_report, dsse_envelope, slsa_provenance, vex_attestation, sbom_attestation, scan_attestation, policy_attestation, approval_attestation';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.relationship IS
|
||||
'Semantic relationship: verified_by, attested_by, signed_by, approved_by, derived_from';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.attestation_ref IS
|
||||
'JSON object containing digest, storage_uri, payload_type, predicate_type, subject_digests, signer_info, rekor_entry';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.verification_result IS
|
||||
'JSON object containing verified (bool), verified_at, verifier, verifier_version, policy_ref, checks, warnings, errors';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.ledger_event_id IS
|
||||
'Reference to the ledger event that recorded this pointer creation';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Migration: 009_snapshots
|
||||
-- Description: Creates ledger_snapshots table for time-travel/snapshot functionality
|
||||
-- Date: 2025-12-07
|
||||
|
||||
-- Create ledger_snapshots table
|
||||
CREATE TABLE IF NOT EXISTS ledger_snapshots (
|
||||
tenant_id TEXT NOT NULL,
|
||||
snapshot_id UUID NOT NULL,
|
||||
label TEXT,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'Creating',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
sequence_number BIGINT NOT NULL,
|
||||
snapshot_timestamp TIMESTAMPTZ NOT NULL,
|
||||
findings_count BIGINT NOT NULL DEFAULT 0,
|
||||
vex_statements_count BIGINT NOT NULL DEFAULT 0,
|
||||
advisories_count BIGINT NOT NULL DEFAULT 0,
|
||||
sboms_count BIGINT NOT NULL DEFAULT 0,
|
||||
events_count BIGINT NOT NULL DEFAULT 0,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
merkle_root TEXT,
|
||||
dsse_digest TEXT,
|
||||
metadata JSONB,
|
||||
include_entity_types JSONB,
|
||||
sign_requested BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (tenant_id, snapshot_id)
|
||||
);
|
||||
|
||||
-- Index for listing snapshots by status
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_status
|
||||
ON ledger_snapshots (tenant_id, status, created_at DESC);
|
||||
|
||||
-- Index for finding expired snapshots
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_expires
|
||||
ON ledger_snapshots (expires_at)
|
||||
WHERE expires_at IS NOT NULL AND status = 'Available';
|
||||
|
||||
-- Index for sequence lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_sequence
|
||||
ON ledger_snapshots (tenant_id, sequence_number);
|
||||
|
||||
-- Index for label search
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_label
|
||||
ON ledger_snapshots (tenant_id, label)
|
||||
WHERE label IS NOT NULL;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE ledger_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policy for tenant isolation
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'ledger_snapshots'
|
||||
AND policyname = 'ledger_snapshots_tenant_isolation'
|
||||
) THEN
|
||||
CREATE POLICY ledger_snapshots_tenant_isolation ON ledger_snapshots
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON TABLE ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';
|
||||
COMMENT ON COLUMN ledger_snapshots.sequence_number IS 'Ledger sequence number at snapshot time';
|
||||
COMMENT ON COLUMN ledger_snapshots.snapshot_timestamp IS 'Timestamp of ledger state captured';
|
||||
COMMENT ON COLUMN ledger_snapshots.merkle_root IS 'Merkle root hash of all events up to sequence_number';
|
||||
COMMENT ON COLUMN ledger_snapshots.dsse_digest IS 'DSSE envelope digest if signed';
|
||||
@@ -0,0 +1,271 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates capability scanning across Node.js/JavaScript source files.
|
||||
/// </summary>
|
||||
internal static class NodeCapabilityScanBuilder
|
||||
{
|
||||
private static readonly string[] SourceExtensions = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
|
||||
|
||||
/// <summary>
|
||||
/// Scans a Node.js project directory for capabilities.
|
||||
/// </summary>
|
||||
public static NodeCapabilityScanResult ScanProject(string projectPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
|
||||
|
||||
if (!Directory.Exists(projectPath))
|
||||
{
|
||||
return NodeCapabilityScanResult.Empty;
|
||||
}
|
||||
|
||||
var allEvidences = new List<NodeCapabilityEvidence>();
|
||||
|
||||
foreach (var sourceFile in EnumerateSourceFiles(projectPath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(sourceFile);
|
||||
var relativePath = Path.GetRelativePath(projectPath, sourceFile);
|
||||
var evidences = NodeCapabilityScanner.ScanFile(content, relativePath);
|
||||
allEvidences.AddRange(evidences);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip inaccessible files
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip inaccessible files
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate and sort for determinism
|
||||
var finalEvidences = allEvidences
|
||||
.DistinctBy(e => e.DeduplicationKey)
|
||||
.OrderBy(e => e.SourceFile, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.SourceLine)
|
||||
.ThenBy(e => e.Kind)
|
||||
.ToList();
|
||||
|
||||
return new NodeCapabilityScanResult(finalEvidences);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a Node.js project from package.json location.
|
||||
/// </summary>
|
||||
public static NodeCapabilityScanResult ScanPackage(string packageJsonPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packageJsonPath);
|
||||
|
||||
var projectDir = File.Exists(packageJsonPath)
|
||||
? Path.GetDirectoryName(packageJsonPath) ?? packageJsonPath
|
||||
: packageJsonPath;
|
||||
|
||||
if (!Directory.Exists(projectDir))
|
||||
{
|
||||
return NodeCapabilityScanResult.Empty;
|
||||
}
|
||||
|
||||
var allEvidences = new List<NodeCapabilityEvidence>();
|
||||
|
||||
// Scan src directory if it exists
|
||||
var srcDir = Path.Combine(projectDir, "src");
|
||||
if (Directory.Exists(srcDir))
|
||||
{
|
||||
var result = ScanProject(srcDir, cancellationToken);
|
||||
allEvidences.AddRange(result.Evidences);
|
||||
}
|
||||
|
||||
// Scan lib directory if it exists
|
||||
var libDir = Path.Combine(projectDir, "lib");
|
||||
if (Directory.Exists(libDir))
|
||||
{
|
||||
var result = ScanProject(libDir, cancellationToken);
|
||||
allEvidences.AddRange(result.Evidences);
|
||||
}
|
||||
|
||||
// Scan root level .js files
|
||||
foreach (var ext in SourceExtensions)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(projectDir, $"*{ext}", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Skip config files
|
||||
var fileName = Path.GetFileName(file);
|
||||
if (IsConfigFile(fileName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
var relativePath = Path.GetRelativePath(projectDir, file);
|
||||
var evidences = NodeCapabilityScanner.ScanFile(content, relativePath);
|
||||
allEvidences.AddRange(evidences);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip inaccessible files
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip inaccessible files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no structured directories found, scan the whole project
|
||||
if (allEvidences.Count == 0)
|
||||
{
|
||||
return ScanProject(projectDir, cancellationToken);
|
||||
}
|
||||
|
||||
var finalEvidences = allEvidences
|
||||
.DistinctBy(e => e.DeduplicationKey)
|
||||
.OrderBy(e => e.SourceFile, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.SourceLine)
|
||||
.ThenBy(e => e.Kind)
|
||||
.ToList();
|
||||
|
||||
return new NodeCapabilityScanResult(finalEvidences);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans specific JavaScript/TypeScript source content.
|
||||
/// </summary>
|
||||
public static NodeCapabilityScanResult ScanContent(string content, string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return NodeCapabilityScanResult.Empty;
|
||||
}
|
||||
|
||||
var evidences = NodeCapabilityScanner.ScanFile(content, filePath);
|
||||
return new NodeCapabilityScanResult(evidences.ToList());
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateSourceFiles(string rootPath)
|
||||
{
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
MaxRecursionDepth = 30
|
||||
};
|
||||
|
||||
foreach (var ext in SourceExtensions)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(rootPath, $"*{ext}", options))
|
||||
{
|
||||
// Skip node_modules
|
||||
if (file.Contains($"{Path.DirectorySeparatorChar}node_modules{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.AltDirectorySeparatorChar}node_modules{Path.AltDirectorySeparatorChar}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip dist/build output directories
|
||||
if (file.Contains($"{Path.DirectorySeparatorChar}dist{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.DirectorySeparatorChar}build{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.DirectorySeparatorChar}out{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.AltDirectorySeparatorChar}dist{Path.AltDirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.AltDirectorySeparatorChar}build{Path.AltDirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.AltDirectorySeparatorChar}out{Path.AltDirectorySeparatorChar}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip coverage directories
|
||||
if (file.Contains($"{Path.DirectorySeparatorChar}coverage{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.AltDirectorySeparatorChar}coverage{Path.AltDirectorySeparatorChar}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip hidden directories
|
||||
if (file.Contains($"{Path.DirectorySeparatorChar}.") ||
|
||||
file.Contains($"{Path.AltDirectorySeparatorChar}."))
|
||||
{
|
||||
// But allow .github, .vscode source files if they contain JS
|
||||
if (!file.Contains(".github") && !file.Contains(".vscode"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip test files (optional - can be useful for scanning)
|
||||
// if (IsTestFile(Path.GetFileName(file)))
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// Skip minified files
|
||||
var fileName = Path.GetFileName(file);
|
||||
if (fileName.Contains(".min.") || fileName.EndsWith(".min.js", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip config files
|
||||
if (IsConfigFile(fileName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsConfigFile(string fileName)
|
||||
{
|
||||
var configPatterns = new[]
|
||||
{
|
||||
"webpack.config",
|
||||
"rollup.config",
|
||||
"vite.config",
|
||||
"babel.config",
|
||||
"jest.config",
|
||||
"eslint.config",
|
||||
"prettier.config",
|
||||
"tsconfig",
|
||||
"jsconfig",
|
||||
".eslintrc",
|
||||
".prettierrc",
|
||||
".babelrc",
|
||||
"karma.conf",
|
||||
"protractor.conf",
|
||||
"gulpfile",
|
||||
"gruntfile",
|
||||
"postcss.config",
|
||||
"tailwind.config",
|
||||
"next.config",
|
||||
"nuxt.config",
|
||||
"svelte.config",
|
||||
"astro.config",
|
||||
"vitest.config"
|
||||
};
|
||||
|
||||
var lowerFileName = fileName.ToLowerInvariant();
|
||||
return configPatterns.Any(p => lowerFileName.Contains(p));
|
||||
}
|
||||
|
||||
// Uncomment if you want to skip test files
|
||||
// private static bool IsTestFile(string fileName)
|
||||
// {
|
||||
// var lowerFileName = fileName.ToLowerInvariant();
|
||||
// return lowerFileName.Contains(".test.") ||
|
||||
// lowerFileName.Contains(".spec.") ||
|
||||
// lowerFileName.Contains("_test.") ||
|
||||
// lowerFileName.Contains("_spec.") ||
|
||||
// lowerFileName.EndsWith(".test.js") ||
|
||||
// lowerFileName.EndsWith(".spec.js") ||
|
||||
// lowerFileName.EndsWith(".test.ts") ||
|
||||
// lowerFileName.EndsWith(".spec.ts");
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Scans Node.js/JavaScript source files for security-relevant capabilities.
|
||||
/// Detects patterns for command execution, file I/O, network access,
|
||||
/// serialization, dynamic code evaluation, native addons, and more.
|
||||
/// </summary>
|
||||
internal static class NodeCapabilityScanner
|
||||
{
|
||||
// ========================================
|
||||
// EXEC - Command/Process Execution (Critical)
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] ExecPatterns =
|
||||
[
|
||||
// child_process module
|
||||
(new Regex(@"require\s*\(\s*['""]child_process['""]", RegexOptions.Compiled), "require('child_process')", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"from\s+['""]child_process['""]", RegexOptions.Compiled), "import child_process", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"child_process\s*\.\s*exec\s*\(", RegexOptions.Compiled), "child_process.exec", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"child_process\s*\.\s*execSync\s*\(", RegexOptions.Compiled), "child_process.execSync", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"child_process\s*\.\s*spawn\s*\(", RegexOptions.Compiled), "child_process.spawn", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"child_process\s*\.\s*spawnSync\s*\(", RegexOptions.Compiled), "child_process.spawnSync", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"child_process\s*\.\s*fork\s*\(", RegexOptions.Compiled), "child_process.fork", CapabilityRisk.High, 0.95f),
|
||||
(new Regex(@"child_process\s*\.\s*execFile\s*\(", RegexOptions.Compiled), "child_process.execFile", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"child_process\s*\.\s*execFileSync\s*\(", RegexOptions.Compiled), "child_process.execFileSync", CapabilityRisk.Critical, 1.0f),
|
||||
|
||||
// Destructured imports
|
||||
(new Regex(@"\{\s*(?:exec|execSync|spawn|spawnSync|fork|execFile)\s*\}", RegexOptions.Compiled), "destructured child_process", CapabilityRisk.Critical, 0.9f),
|
||||
|
||||
// Shell execution via execa, shelljs, etc.
|
||||
(new Regex(@"require\s*\(\s*['""]execa['""]", RegexOptions.Compiled), "require('execa')", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"require\s*\(\s*['""]shelljs['""]", RegexOptions.Compiled), "require('shelljs')", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"shell\s*\.\s*exec\s*\(", RegexOptions.Compiled), "shelljs.exec", CapabilityRisk.Critical, 0.9f),
|
||||
|
||||
// process.binding for internal access
|
||||
(new Regex(@"process\s*\.\s*binding\s*\(", RegexOptions.Compiled), "process.binding", CapabilityRisk.Critical, 0.95f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// FILESYSTEM - File/Directory Operations
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] FilesystemPatterns =
|
||||
[
|
||||
// fs module
|
||||
(new Regex(@"require\s*\(\s*['""]fs['""]", RegexOptions.Compiled), "require('fs')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]fs/promises['""]", RegexOptions.Compiled), "require('fs/promises')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"from\s+['""]fs['""]", RegexOptions.Compiled), "import fs", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"from\s+['""]fs/promises['""]", RegexOptions.Compiled), "import fs/promises", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"from\s+['""]node:fs['""]", RegexOptions.Compiled), "import node:fs", CapabilityRisk.Medium, 0.9f),
|
||||
|
||||
// Read operations
|
||||
(new Regex(@"fs\s*\.\s*readFile(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.readFile", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"fs\s*\.\s*readdir(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.readdir", CapabilityRisk.Medium, 0.8f),
|
||||
(new Regex(@"fs\s*\.\s*createReadStream\s*\(", RegexOptions.Compiled), "fs.createReadStream", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// Write operations (higher risk)
|
||||
(new Regex(@"fs\s*\.\s*writeFile(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.writeFile", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"fs\s*\.\s*appendFile(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.appendFile", CapabilityRisk.High, 0.85f),
|
||||
(new Regex(@"fs\s*\.\s*createWriteStream\s*\(", RegexOptions.Compiled), "fs.createWriteStream", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"fs\s*\.\s*mkdir(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.mkdir", CapabilityRisk.Medium, 0.8f),
|
||||
|
||||
// Delete operations (high risk)
|
||||
(new Regex(@"fs\s*\.\s*unlink(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.unlink", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"fs\s*\.\s*rmdir(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.rmdir", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"fs\s*\.\s*rm(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.rm", CapabilityRisk.High, 0.9f),
|
||||
|
||||
// Permission operations
|
||||
(new Regex(@"fs\s*\.\s*chmod(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.chmod", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"fs\s*\.\s*chown(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.chown", CapabilityRisk.High, 0.9f),
|
||||
|
||||
// Symlink (can be used for path traversal)
|
||||
(new Regex(@"fs\s*\.\s*symlink(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.symlink", CapabilityRisk.High, 0.85f),
|
||||
|
||||
// fs-extra
|
||||
(new Regex(@"require\s*\(\s*['""]fs-extra['""]", RegexOptions.Compiled), "require('fs-extra')", CapabilityRisk.Medium, 0.85f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// NETWORK - Network I/O
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] NetworkPatterns =
|
||||
[
|
||||
// Core modules
|
||||
(new Regex(@"require\s*\(\s*['""]net['""]", RegexOptions.Compiled), "require('net')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]http['""]", RegexOptions.Compiled), "require('http')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]https['""]", RegexOptions.Compiled), "require('https')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]dgram['""]", RegexOptions.Compiled), "require('dgram')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]tls['""]", RegexOptions.Compiled), "require('tls')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"from\s+['""]node:(?:net|http|https|dgram|tls)['""]", RegexOptions.Compiled), "import node:network", CapabilityRisk.Medium, 0.9f),
|
||||
|
||||
// Socket operations
|
||||
(new Regex(@"net\s*\.\s*createServer\s*\(", RegexOptions.Compiled), "net.createServer", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"net\s*\.\s*createConnection\s*\(", RegexOptions.Compiled), "net.createConnection", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"net\s*\.\s*connect\s*\(", RegexOptions.Compiled), "net.connect", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// HTTP operations
|
||||
(new Regex(@"http\s*\.\s*createServer\s*\(", RegexOptions.Compiled), "http.createServer", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"http\s*\.\s*request\s*\(", RegexOptions.Compiled), "http.request", CapabilityRisk.Medium, 0.8f),
|
||||
(new Regex(@"http\s*\.\s*get\s*\(", RegexOptions.Compiled), "http.get", CapabilityRisk.Medium, 0.8f),
|
||||
(new Regex(@"https\s*\.\s*request\s*\(", RegexOptions.Compiled), "https.request", CapabilityRisk.Medium, 0.8f),
|
||||
|
||||
// Fetch API
|
||||
(new Regex(@"\bfetch\s*\(", RegexOptions.Compiled), "fetch", CapabilityRisk.Medium, 0.75f),
|
||||
(new Regex(@"require\s*\(\s*['""]node-fetch['""]", RegexOptions.Compiled), "require('node-fetch')", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// Axios, got, request
|
||||
(new Regex(@"require\s*\(\s*['""]axios['""]", RegexOptions.Compiled), "require('axios')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]got['""]", RegexOptions.Compiled), "require('got')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]request['""]", RegexOptions.Compiled), "require('request')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]superagent['""]", RegexOptions.Compiled), "require('superagent')", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// WebSocket
|
||||
(new Regex(@"require\s*\(\s*['""]ws['""]", RegexOptions.Compiled), "require('ws')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"new\s+WebSocket\s*\(", RegexOptions.Compiled), "WebSocket", CapabilityRisk.Medium, 0.8f),
|
||||
|
||||
// DNS
|
||||
(new Regex(@"require\s*\(\s*['""]dns['""]", RegexOptions.Compiled), "require('dns')", CapabilityRisk.Low, 0.8f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// ENVIRONMENT - Environment Variables
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] EnvironmentPatterns =
|
||||
[
|
||||
(new Regex(@"process\s*\.\s*env\b", RegexOptions.Compiled), "process.env", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"process\s*\.\s*env\s*\[", RegexOptions.Compiled), "process.env[]", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"process\s*\.\s*env\s*\.\s*\w+", RegexOptions.Compiled), "process.env.*", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// dotenv
|
||||
(new Regex(@"require\s*\(\s*['""]dotenv['""]", RegexOptions.Compiled), "require('dotenv')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"dotenv\s*\.\s*config\s*\(", RegexOptions.Compiled), "dotenv.config", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// process info
|
||||
(new Regex(@"process\s*\.\s*cwd\s*\(\s*\)", RegexOptions.Compiled), "process.cwd", CapabilityRisk.Low, 0.75f),
|
||||
(new Regex(@"process\s*\.\s*chdir\s*\(", RegexOptions.Compiled), "process.chdir", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"process\s*\.\s*argv\b", RegexOptions.Compiled), "process.argv", CapabilityRisk.Low, 0.7f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// SERIALIZATION - Data Serialization
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] SerializationPatterns =
|
||||
[
|
||||
// JSON.parse with reviver (potential code execution)
|
||||
(new Regex(@"JSON\s*\.\s*parse\s*\([^,)]+,\s*\w+", RegexOptions.Compiled), "JSON.parse with reviver", CapabilityRisk.Medium, 0.7f),
|
||||
|
||||
// Dangerous serializers - node-serialize is known vulnerable
|
||||
(new Regex(@"require\s*\(\s*['""]node-serialize['""]", RegexOptions.Compiled), "require('node-serialize')", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"serialize\s*\.\s*unserialize\s*\(", RegexOptions.Compiled), "node-serialize.unserialize", CapabilityRisk.Critical, 1.0f),
|
||||
|
||||
// serialize-javascript
|
||||
(new Regex(@"require\s*\(\s*['""]serialize-javascript['""]", RegexOptions.Compiled), "require('serialize-javascript')", CapabilityRisk.High, 0.85f),
|
||||
|
||||
// js-yaml (load is unsafe by default in older versions)
|
||||
(new Regex(@"require\s*\(\s*['""]js-yaml['""]", RegexOptions.Compiled), "require('js-yaml')", CapabilityRisk.Medium, 0.8f),
|
||||
(new Regex(@"yaml\s*\.\s*load\s*\(", RegexOptions.Compiled), "yaml.load", CapabilityRisk.High, 0.85f),
|
||||
|
||||
// Pickle-like serializers
|
||||
(new Regex(@"require\s*\(\s*['""]v8['""]", RegexOptions.Compiled), "require('v8')", CapabilityRisk.High, 0.85f),
|
||||
(new Regex(@"v8\s*\.\s*deserialize\s*\(", RegexOptions.Compiled), "v8.deserialize", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"v8\s*\.\s*serialize\s*\(", RegexOptions.Compiled), "v8.serialize", CapabilityRisk.Medium, 0.8f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// CRYPTO - Cryptographic Operations
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] CryptoPatterns =
|
||||
[
|
||||
(new Regex(@"require\s*\(\s*['""]crypto['""]", RegexOptions.Compiled), "require('crypto')", CapabilityRisk.Low, 0.85f),
|
||||
(new Regex(@"from\s+['""](?:node:)?crypto['""]", RegexOptions.Compiled), "import crypto", CapabilityRisk.Low, 0.85f),
|
||||
|
||||
// Specific crypto operations
|
||||
(new Regex(@"crypto\s*\.\s*createHash\s*\(", RegexOptions.Compiled), "crypto.createHash", CapabilityRisk.Low, 0.85f),
|
||||
(new Regex(@"crypto\s*\.\s*createCipher(?:iv)?\s*\(", RegexOptions.Compiled), "crypto.createCipher", CapabilityRisk.Low, 0.85f),
|
||||
(new Regex(@"crypto\s*\.\s*createDecipher(?:iv)?\s*\(", RegexOptions.Compiled), "crypto.createDecipher", CapabilityRisk.Low, 0.85f),
|
||||
(new Regex(@"crypto\s*\.\s*createSign\s*\(", RegexOptions.Compiled), "crypto.createSign", CapabilityRisk.Low, 0.85f),
|
||||
(new Regex(@"crypto\s*\.\s*createVerify\s*\(", RegexOptions.Compiled), "crypto.createVerify", CapabilityRisk.Low, 0.85f),
|
||||
(new Regex(@"crypto\s*\.\s*randomBytes\s*\(", RegexOptions.Compiled), "crypto.randomBytes", CapabilityRisk.Low, 0.8f),
|
||||
(new Regex(@"crypto\s*\.\s*pbkdf2\s*\(", RegexOptions.Compiled), "crypto.pbkdf2", CapabilityRisk.Low, 0.85f),
|
||||
|
||||
// Third-party crypto
|
||||
(new Regex(@"require\s*\(\s*['""]bcrypt['""]", RegexOptions.Compiled), "require('bcrypt')", CapabilityRisk.Low, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]argon2['""]", RegexOptions.Compiled), "require('argon2')", CapabilityRisk.Low, 0.85f),
|
||||
|
||||
// Weak crypto
|
||||
(new Regex(@"createHash\s*\(\s*['""](?:md5|sha1)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Weak hash algorithm", CapabilityRisk.High, 0.9f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// DATABASE - Database Access
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] DatabasePatterns =
|
||||
[
|
||||
// SQL databases
|
||||
(new Regex(@"require\s*\(\s*['""]mysql2?['""]", RegexOptions.Compiled), "require('mysql')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]pg['""]", RegexOptions.Compiled), "require('pg')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]sqlite3['""]", RegexOptions.Compiled), "require('sqlite3')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]better-sqlite3['""]", RegexOptions.Compiled), "require('better-sqlite3')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]mssql['""]", RegexOptions.Compiled), "require('mssql')", CapabilityRisk.Medium, 0.9f),
|
||||
|
||||
// NoSQL databases
|
||||
(new Regex(@"require\s*\(\s*['""]mongodb['""]", RegexOptions.Compiled), "require('mongodb')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]mongoose['""]", RegexOptions.Compiled), "require('mongoose')", CapabilityRisk.Medium, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]redis['""]", RegexOptions.Compiled), "require('redis')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]ioredis['""]", RegexOptions.Compiled), "require('ioredis')", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// Query execution
|
||||
(new Regex(@"\.query\s*\(\s*[`'""](?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Raw SQL query", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"\.exec\s*\(\s*[`'""](?:SELECT|INSERT|UPDATE|DELETE)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Raw SQL exec", CapabilityRisk.High, 0.85f),
|
||||
|
||||
// SQL injection patterns - string concatenation
|
||||
(new Regex(@"[`'""](?:SELECT|INSERT|UPDATE|DELETE)\s+.*[`'""]\s*\+", RegexOptions.Compiled | RegexOptions.IgnoreCase), "SQL string concatenation", CapabilityRisk.Critical, 0.9f),
|
||||
(new Regex(@"\$\{.*\}.*(?:SELECT|INSERT|UPDATE|DELETE)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "SQL template literal injection", CapabilityRisk.Critical, 0.85f),
|
||||
|
||||
// ORMs
|
||||
(new Regex(@"require\s*\(\s*['""]sequelize['""]", RegexOptions.Compiled), "require('sequelize')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]typeorm['""]", RegexOptions.Compiled), "require('typeorm')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]prisma['""]", RegexOptions.Compiled), "require('prisma')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"require\s*\(\s*['""]knex['""]", RegexOptions.Compiled), "require('knex')", CapabilityRisk.Medium, 0.85f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// DYNAMIC CODE - Code Evaluation (Critical)
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] DynamicCodePatterns =
|
||||
[
|
||||
// eval - most dangerous
|
||||
(new Regex(@"\beval\s*\(", RegexOptions.Compiled), "eval", CapabilityRisk.Critical, 1.0f),
|
||||
|
||||
// Function constructor
|
||||
(new Regex(@"new\s+Function\s*\(", RegexOptions.Compiled), "new Function", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"Function\s*\(\s*[^)]+\)\s*\(", RegexOptions.Compiled), "Function()()", CapabilityRisk.Critical, 0.95f),
|
||||
|
||||
// vm module
|
||||
(new Regex(@"require\s*\(\s*['""]vm['""]", RegexOptions.Compiled), "require('vm')", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"from\s+['""](?:node:)?vm['""]", RegexOptions.Compiled), "import vm", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"vm\s*\.\s*runInContext\s*\(", RegexOptions.Compiled), "vm.runInContext", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"vm\s*\.\s*runInNewContext\s*\(", RegexOptions.Compiled), "vm.runInNewContext", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"vm\s*\.\s*runInThisContext\s*\(", RegexOptions.Compiled), "vm.runInThisContext", CapabilityRisk.Critical, 1.0f),
|
||||
(new Regex(@"vm\s*\.\s*Script\s*\(", RegexOptions.Compiled), "vm.Script", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"new\s+vm\s*\.\s*Script\s*\(", RegexOptions.Compiled), "new vm.Script", CapabilityRisk.Critical, 0.95f),
|
||||
|
||||
// setTimeout/setInterval with strings (eval-like)
|
||||
(new Regex(@"setTimeout\s*\(\s*['""`]", RegexOptions.Compiled), "setTimeout with string", CapabilityRisk.Critical, 0.9f),
|
||||
(new Regex(@"setInterval\s*\(\s*['""`]", RegexOptions.Compiled), "setInterval with string", CapabilityRisk.Critical, 0.9f),
|
||||
|
||||
// Template engines (can execute code)
|
||||
(new Regex(@"require\s*\(\s*['""]ejs['""]", RegexOptions.Compiled), "require('ejs')", CapabilityRisk.High, 0.8f),
|
||||
(new Regex(@"require\s*\(\s*['""]pug['""]", RegexOptions.Compiled), "require('pug')", CapabilityRisk.Medium, 0.75f),
|
||||
(new Regex(@"require\s*\(\s*['""]handlebars['""]", RegexOptions.Compiled), "require('handlebars')", CapabilityRisk.Medium, 0.7f),
|
||||
|
||||
// vm2 (sandbox escape vulnerabilities)
|
||||
(new Regex(@"require\s*\(\s*['""]vm2['""]", RegexOptions.Compiled), "require('vm2')", CapabilityRisk.High, 0.9f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// REFLECTION - Code Introspection
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] ReflectionPatterns =
|
||||
[
|
||||
// Reflect API
|
||||
(new Regex(@"Reflect\s*\.\s*(?:get|set|has|defineProperty|deleteProperty|apply|construct)\s*\(", RegexOptions.Compiled), "Reflect.*", CapabilityRisk.Medium, 0.8f),
|
||||
|
||||
// Proxy
|
||||
(new Regex(@"new\s+Proxy\s*\(", RegexOptions.Compiled), "new Proxy", CapabilityRisk.Medium, 0.8f),
|
||||
|
||||
// Property access via bracket notation with variables
|
||||
(new Regex(@"\[\s*\w+\s*\]\s*\(", RegexOptions.Compiled), "Dynamic property call", CapabilityRisk.Medium, 0.65f),
|
||||
|
||||
// Object introspection
|
||||
(new Regex(@"Object\s*\.\s*getOwnPropertyDescriptor\s*\(", RegexOptions.Compiled), "Object.getOwnPropertyDescriptor", CapabilityRisk.Low, 0.7f),
|
||||
(new Regex(@"Object\s*\.\s*getPrototypeOf\s*\(", RegexOptions.Compiled), "Object.getPrototypeOf", CapabilityRisk.Low, 0.7f),
|
||||
(new Regex(@"Object\s*\.\s*setPrototypeOf\s*\(", RegexOptions.Compiled), "Object.setPrototypeOf", CapabilityRisk.High, 0.85f),
|
||||
(new Regex(@"__proto__", RegexOptions.Compiled), "__proto__", CapabilityRisk.High, 0.9f),
|
||||
|
||||
// constructor access
|
||||
(new Regex(@"\.constructor\s*\(", RegexOptions.Compiled), ".constructor()", CapabilityRisk.High, 0.85f),
|
||||
(new Regex(@"\[['""]\s*constructor\s*['""]", RegexOptions.Compiled), "['constructor']", CapabilityRisk.High, 0.85f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// NATIVE CODE - Native Addons
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] NativeCodePatterns =
|
||||
[
|
||||
// Native addon loading
|
||||
(new Regex(@"require\s*\([^)]*\.node['""]?\s*\)", RegexOptions.Compiled), "require('.node')", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"process\s*\.\s*dlopen\s*\(", RegexOptions.Compiled), "process.dlopen", CapabilityRisk.Critical, 1.0f),
|
||||
|
||||
// N-API / node-addon-api
|
||||
(new Regex(@"require\s*\(\s*['""]node-addon-api['""]", RegexOptions.Compiled), "require('node-addon-api')", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"require\s*\(\s*['""]bindings['""]", RegexOptions.Compiled), "require('bindings')", CapabilityRisk.High, 0.9f),
|
||||
|
||||
// FFI
|
||||
(new Regex(@"require\s*\(\s*['""]ffi-napi['""]", RegexOptions.Compiled), "require('ffi-napi')", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"require\s*\(\s*['""]node-ffi['""]", RegexOptions.Compiled), "require('node-ffi')", CapabilityRisk.Critical, 0.95f),
|
||||
(new Regex(@"require\s*\(\s*['""]ref-napi['""]", RegexOptions.Compiled), "require('ref-napi')", CapabilityRisk.High, 0.9f),
|
||||
|
||||
// WebAssembly
|
||||
(new Regex(@"WebAssembly\s*\.\s*instantiate\s*\(", RegexOptions.Compiled), "WebAssembly.instantiate", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"WebAssembly\s*\.\s*compile\s*\(", RegexOptions.Compiled), "WebAssembly.compile", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"new\s+WebAssembly\s*\.\s*Module\s*\(", RegexOptions.Compiled), "new WebAssembly.Module", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"new\s+WebAssembly\s*\.\s*Instance\s*\(", RegexOptions.Compiled), "new WebAssembly.Instance", CapabilityRisk.High, 0.9f),
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// OTHER - Worker threads, cluster, etc.
|
||||
// ========================================
|
||||
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] OtherPatterns =
|
||||
[
|
||||
// Worker threads
|
||||
(new Regex(@"require\s*\(\s*['""]worker_threads['""]", RegexOptions.Compiled), "require('worker_threads')", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"from\s+['""](?:node:)?worker_threads['""]", RegexOptions.Compiled), "import worker_threads", CapabilityRisk.Medium, 0.85f),
|
||||
(new Regex(@"new\s+Worker\s*\(", RegexOptions.Compiled), "new Worker", CapabilityRisk.Medium, 0.8f),
|
||||
|
||||
// Cluster
|
||||
(new Regex(@"require\s*\(\s*['""]cluster['""]", RegexOptions.Compiled), "require('cluster')", CapabilityRisk.Medium, 0.8f),
|
||||
(new Regex(@"cluster\s*\.\s*fork\s*\(", RegexOptions.Compiled), "cluster.fork", CapabilityRisk.Medium, 0.85f),
|
||||
|
||||
// Process manipulation
|
||||
(new Regex(@"process\s*\.\s*exit\s*\(", RegexOptions.Compiled), "process.exit", CapabilityRisk.Medium, 0.8f),
|
||||
(new Regex(@"process\s*\.\s*kill\s*\(", RegexOptions.Compiled), "process.kill", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"process\s*\.\s*abort\s*\(", RegexOptions.Compiled), "process.abort", CapabilityRisk.High, 0.9f),
|
||||
|
||||
// Module loading
|
||||
(new Regex(@"require\s*\.\s*resolve\s*\(", RegexOptions.Compiled), "require.resolve", CapabilityRisk.Low, 0.7f),
|
||||
(new Regex(@"import\s*\(", RegexOptions.Compiled), "dynamic import()", CapabilityRisk.Medium, 0.75f),
|
||||
(new Regex(@"require\s*\(\s*\w+\s*\)", RegexOptions.Compiled), "require(variable)", CapabilityRisk.High, 0.85f),
|
||||
|
||||
// Inspector/debugger
|
||||
(new Regex(@"require\s*\(\s*['""]inspector['""]", RegexOptions.Compiled), "require('inspector')", CapabilityRisk.High, 0.9f),
|
||||
(new Regex(@"\bdebugger\b", RegexOptions.Compiled), "debugger statement", CapabilityRisk.Medium, 0.75f),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Scans a Node.js source file for capability usages.
|
||||
/// </summary>
|
||||
public static IEnumerable<NodeCapabilityEvidence> ScanFile(string content, string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Strip comments for more accurate detection
|
||||
var cleanedContent = StripComments(content);
|
||||
var lines = cleanedContent.Split('\n');
|
||||
|
||||
for (var lineNumber = 0; lineNumber < lines.Length; lineNumber++)
|
||||
{
|
||||
var line = lines[lineNumber];
|
||||
var lineNum = lineNumber + 1;
|
||||
|
||||
// Exec patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, ExecPatterns, CapabilityKind.Exec))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Filesystem patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, FilesystemPatterns, CapabilityKind.Filesystem))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Network patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, NetworkPatterns, CapabilityKind.Network))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Environment patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, EnvironmentPatterns, CapabilityKind.Environment))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Serialization patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, SerializationPatterns, CapabilityKind.Serialization))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Crypto patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, CryptoPatterns, CapabilityKind.Crypto))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Database patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, DatabasePatterns, CapabilityKind.Database))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Dynamic code patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, DynamicCodePatterns, CapabilityKind.DynamicCode))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Reflection patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, ReflectionPatterns, CapabilityKind.Reflection))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Native code patterns
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, NativeCodePatterns, CapabilityKind.NativeCode))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
|
||||
// Other patterns (workers, process, etc.)
|
||||
foreach (var evidence in ScanPatterns(line, lineNum, filePath, OtherPatterns, CapabilityKind.Other))
|
||||
{
|
||||
yield return evidence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<NodeCapabilityEvidence> ScanPatterns(
|
||||
string line,
|
||||
int lineNumber,
|
||||
string filePath,
|
||||
(Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] patterns,
|
||||
CapabilityKind kind)
|
||||
{
|
||||
foreach (var (pattern, name, risk, confidence) in patterns)
|
||||
{
|
||||
if (pattern.IsMatch(line))
|
||||
{
|
||||
yield return new NodeCapabilityEvidence(
|
||||
kind: kind,
|
||||
sourceFile: filePath,
|
||||
sourceLine: lineNumber,
|
||||
pattern: name,
|
||||
snippet: line.Trim(),
|
||||
confidence: confidence,
|
||||
risk: risk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips single-line (//) and multi-line (/* */) comments from JavaScript source.
|
||||
/// </summary>
|
||||
private static string StripComments(string content)
|
||||
{
|
||||
var sb = new StringBuilder(content.Length);
|
||||
var i = 0;
|
||||
var inString = false;
|
||||
var inTemplate = false;
|
||||
var stringChar = '"';
|
||||
|
||||
while (i < content.Length)
|
||||
{
|
||||
// Handle escape sequences in strings
|
||||
if ((inString || inTemplate) && content[i] == '\\' && i + 1 < content.Length)
|
||||
{
|
||||
sb.Append(content[i]);
|
||||
sb.Append(content[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle template literals
|
||||
if (!inString && content[i] == '`')
|
||||
{
|
||||
inTemplate = !inTemplate;
|
||||
sb.Append(content[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle string literals (but not inside template literals)
|
||||
if (!inTemplate && (content[i] == '"' || content[i] == '\''))
|
||||
{
|
||||
if (!inString)
|
||||
{
|
||||
inString = true;
|
||||
stringChar = content[i];
|
||||
}
|
||||
else if (content[i] == stringChar)
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
sb.Append(content[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip comments only when not in string/template
|
||||
if (!inString && !inTemplate)
|
||||
{
|
||||
// Single-line comment
|
||||
if (i + 1 < content.Length && content[i] == '/' && content[i + 1] == '/')
|
||||
{
|
||||
// Skip until end of line
|
||||
while (i < content.Length && content[i] != '\n')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
if (i < content.Length)
|
||||
{
|
||||
sb.Append('\n');
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-line comment
|
||||
if (i + 1 < content.Length && content[i] == '/' && content[i + 1] == '*')
|
||||
{
|
||||
i += 2;
|
||||
while (i + 1 < content.Length && !(content[i] == '*' && content[i + 1] == '/'))
|
||||
{
|
||||
// Preserve newlines for line number accuracy
|
||||
if (content[i] == '\n')
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i + 1 < content.Length)
|
||||
{
|
||||
i += 2; // Skip */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(content[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
|
||||
|
||||
public sealed class ComposerLockReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public ComposerLockReaderTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"php-lock-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NoLockFile_ReturnsEmpty()
|
||||
{
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsEmpty);
|
||||
Assert.Empty(result.Packages);
|
||||
Assert.Empty(result.DevPackages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ValidLockFile_ParsesPackages()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""content-hash"": ""abc123def456"",
|
||||
""plugin-api-version"": ""2.6.0"",
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.2.3"",
|
||||
""type"": ""library""
|
||||
}
|
||||
],
|
||||
""packages-dev"": []
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsEmpty);
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("vendor/package", result.Packages[0].Name);
|
||||
Assert.Equal("1.2.3", result.Packages[0].Version);
|
||||
Assert.Equal("library", result.Packages[0].Type);
|
||||
Assert.False(result.Packages[0].IsDev);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesDevPackages()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [],
|
||||
""packages-dev"": [
|
||||
{
|
||||
""name"": ""phpunit/phpunit"",
|
||||
""version"": ""10.0.0"",
|
||||
""type"": ""library""
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.DevPackages);
|
||||
Assert.Equal("phpunit/phpunit", result.DevPackages[0].Name);
|
||||
Assert.True(result.DevPackages[0].IsDev);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesContentHashAndPluginApi()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""content-hash"": ""a1b2c3d4e5f6"",
|
||||
""plugin-api-version"": ""2.3.0"",
|
||||
""packages"": []
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal("a1b2c3d4e5f6", result.ContentHash);
|
||||
Assert.Equal("2.3.0", result.PluginApiVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesSourceInfo()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.0.0"",
|
||||
""source"": {
|
||||
""type"": ""git"",
|
||||
""url"": ""https://github.com/vendor/package.git"",
|
||||
""reference"": ""abc123def456789""
|
||||
}
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("git", result.Packages[0].SourceType);
|
||||
Assert.Equal("abc123def456789", result.Packages[0].SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesDistInfo()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.0.0"",
|
||||
""dist"": {
|
||||
""type"": ""zip"",
|
||||
""url"": ""https://packagist.org/vendor/package/1.0.0"",
|
||||
""shasum"": ""sha256hashhere""
|
||||
}
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("sha256hashhere", result.Packages[0].DistSha);
|
||||
Assert.Equal("https://packagist.org/vendor/package/1.0.0", result.Packages[0].DistUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesAutoloadPsr4()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.0.0"",
|
||||
""autoload"": {
|
||||
""psr-4"": {
|
||||
""Vendor\\Package\\"": ""src/""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.NotEmpty(result.Packages[0].Autoload.Psr4);
|
||||
Assert.Contains("Vendor\\Package\\->src/", result.Packages[0].Autoload.Psr4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesAutoloadClassmap()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.0.0"",
|
||||
""autoload"": {
|
||||
""classmap"": [
|
||||
""src/"",
|
||||
""lib/""
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal(2, result.Packages[0].Autoload.Classmap.Count);
|
||||
Assert.Contains("src/", result.Packages[0].Autoload.Classmap);
|
||||
Assert.Contains("lib/", result.Packages[0].Autoload.Classmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesAutoloadFiles()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.0.0"",
|
||||
""autoload"": {
|
||||
""files"": [
|
||||
""src/helpers.php"",
|
||||
""src/functions.php""
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal(2, result.Packages[0].Autoload.Files.Count);
|
||||
Assert.Contains("src/helpers.php", result.Packages[0].Autoload.Files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MultiplePackages_ParsesAll()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{ ""name"": ""vendor/first"", ""version"": ""1.0.0"" },
|
||||
{ ""name"": ""vendor/second"", ""version"": ""2.0.0"" },
|
||||
{ ""name"": ""vendor/third"", ""version"": ""3.0.0"" }
|
||||
],
|
||||
""packages-dev"": [
|
||||
{ ""name"": ""dev/tool"", ""version"": ""0.1.0"" }
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, result.Packages.Count);
|
||||
Assert.Single(result.DevPackages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ComputesSha256()
|
||||
{
|
||||
var lockContent = @"{ ""packages"": [] }";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result.LockSha256);
|
||||
Assert.Equal(64, result.LockSha256.Length); // SHA256 hex string length
|
||||
Assert.True(result.LockSha256.All(c => char.IsAsciiHexDigitLower(c)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SetsLockPath()
|
||||
{
|
||||
var lockContent = @"{ ""packages"": [] }";
|
||||
var lockPath = Path.Combine(_testDir, "composer.lock");
|
||||
await File.WriteAllTextAsync(lockPath, lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(lockPath, result.LockPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingRequiredFields_SkipsPackage()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{ ""name"": ""valid/package"", ""version"": ""1.0.0"" },
|
||||
{ ""name"": ""missing-version"" },
|
||||
{ ""version"": ""1.0.0"" }
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("valid/package", result.Packages[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_ReturnsEmptyInstance()
|
||||
{
|
||||
var empty = ComposerLockData.Empty;
|
||||
|
||||
Assert.True(empty.IsEmpty);
|
||||
Assert.Empty(empty.Packages);
|
||||
Assert.Empty(empty.DevPackages);
|
||||
Assert.Equal(string.Empty, empty.LockPath);
|
||||
Assert.Null(empty.ContentHash);
|
||||
Assert.Null(empty.PluginApiVersion);
|
||||
Assert.Null(empty.LockSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_Psr4ArrayPaths_ParsesMultiplePaths()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.0.0"",
|
||||
""autoload"": {
|
||||
""psr-4"": {
|
||||
""Vendor\\Package\\"": [""src/"", ""lib/""]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal(2, result.Packages[0].Autoload.Psr4.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NormalizesBackslashesInPaths()
|
||||
{
|
||||
var lockContent = @"{
|
||||
""packages"": [
|
||||
{
|
||||
""name"": ""vendor/package"",
|
||||
""version"": ""1.0.0"",
|
||||
""autoload"": {
|
||||
""files"": [""src\\helpers.php""]
|
||||
}
|
||||
}
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
|
||||
|
||||
var context = CreateContext(_testDir);
|
||||
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Contains("src/helpers.php", result.Packages[0].Autoload.Files);
|
||||
}
|
||||
|
||||
private static LanguageAnalyzerContext CreateContext(string rootPath)
|
||||
{
|
||||
return new LanguageAnalyzerContext(rootPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
|
||||
|
||||
public sealed class PhpCapabilityScannerTests
|
||||
{
|
||||
#region Exec Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("exec('ls -la');", "exec")]
|
||||
[InlineData("shell_exec('whoami');", "shell_exec")]
|
||||
[InlineData("system('cat /etc/passwd');", "system")]
|
||||
[InlineData("passthru('top');", "passthru")]
|
||||
[InlineData("popen('/bin/sh', 'r');", "popen")]
|
||||
[InlineData("proc_open('ls', $descriptors, $pipes);", "proc_open")]
|
||||
[InlineData("pcntl_exec('/bin/bash');", "pcntl_exec")]
|
||||
public void ScanContent_ExecFunction_DetectsCriticalRisk(string line, string expectedFunction)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Exec && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.All(result.Where(e => e.Kind == PhpCapabilityKind.Exec), e => Assert.Equal(PhpCapabilityRisk.Critical, e.Risk));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_BacktickOperator_DetectsCriticalRisk()
|
||||
{
|
||||
var content = "<?php\n$output = `ls -la`;";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Exec && e.FunctionOrPattern == "backtick_operator");
|
||||
Assert.Contains(result, e => e.Risk == PhpCapabilityRisk.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_ExecInComment_DoesNotDetect()
|
||||
{
|
||||
var content = @"<?php
|
||||
// exec('ls -la');
|
||||
/* shell_exec('whoami'); */
|
||||
# system('cat');
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filesystem Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("fopen('file.txt', 'r');", "fopen", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("fwrite($fp, $data);", "fwrite", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("fread($fp, 1024);", "fread", PhpCapabilityRisk.Low)]
|
||||
[InlineData("file_get_contents('data.txt');", "file_get_contents", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("file_put_contents('out.txt', $data);", "file_put_contents", PhpCapabilityRisk.Medium)]
|
||||
public void ScanContent_FileReadWrite_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Filesystem && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unlink('file.txt');", "unlink", PhpCapabilityRisk.High)]
|
||||
[InlineData("rmdir('/tmp/dir');", "rmdir", PhpCapabilityRisk.High)]
|
||||
[InlineData("chmod('script.sh', 0755);", "chmod", PhpCapabilityRisk.High)]
|
||||
[InlineData("chown('file.txt', 'root');", "chown", PhpCapabilityRisk.High)]
|
||||
[InlineData("symlink('/etc/passwd', 'link');", "symlink", PhpCapabilityRisk.High)]
|
||||
public void ScanContent_DangerousFileOps_DetectsHighRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Filesystem && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_DirectoryFunctions_DetectsLowRisk()
|
||||
{
|
||||
var content = @"<?php
|
||||
$files = scandir('/var/www');
|
||||
$matches = glob('*.php');
|
||||
$dir = opendir('/home');
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.All(result.Where(e => e.Kind == PhpCapabilityKind.Filesystem), e => Assert.Equal(PhpCapabilityRisk.Low, e.Risk));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Network Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("curl_init('http://example.com');", "curl_init")]
|
||||
[InlineData("curl_exec($ch);", "curl_exec")]
|
||||
[InlineData("curl_multi_exec($mh, $active);", "curl_multi_exec")]
|
||||
public void ScanContent_CurlFunctions_DetectsMediumRisk(string line, string expectedFunction)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Network && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.Medium, evidence.Risk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fsockopen('localhost', 80);", "fsockopen")]
|
||||
[InlineData("socket_create(AF_INET, SOCK_STREAM, SOL_TCP);", "socket_create")]
|
||||
[InlineData("socket_connect($socket, '127.0.0.1', 8080);", "socket_connect")]
|
||||
[InlineData("stream_socket_client('tcp://localhost:80');", "stream_socket_client")]
|
||||
[InlineData("stream_socket_server('tcp://0.0.0.0:8000');", "stream_socket_server")]
|
||||
public void ScanContent_SocketFunctions_DetectsHighRisk(string line, string expectedFunction)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Network && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_FileGetContentsWithUrl_DetectsNetworkCapability()
|
||||
{
|
||||
var content = "<?php\n$data = file_get_contents('http://example.com/api');";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Network && e.FunctionOrPattern == "file_get_contents_url");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environment Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("getenv('HOME');", "getenv", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("putenv('PATH=/usr/bin');", "putenv", PhpCapabilityRisk.High)]
|
||||
[InlineData("apache_getenv('DOCUMENT_ROOT');", "apache_getenv", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("apache_setenv('MY_VAR', 'value');", "apache_setenv", PhpCapabilityRisk.High)]
|
||||
public void ScanContent_EnvFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Environment && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_EnvSuperglobal_DetectsMediumRisk()
|
||||
{
|
||||
var content = "<?php\n$path = $_ENV['PATH'];";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Environment && e.FunctionOrPattern == "$_ENV");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_ServerSuperglobal_DetectsLowRisk()
|
||||
{
|
||||
var content = "<?php\n$host = $_SERVER['HTTP_HOST'];";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Environment && e.FunctionOrPattern == "$_SERVER");
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.Low, evidence.Risk);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Capabilities
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_Unserialize_DetectsCriticalRisk()
|
||||
{
|
||||
var content = "<?php\n$obj = unserialize($data);";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Serialization && e.FunctionOrPattern == "unserialize");
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.Critical, evidence.Risk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("serialize($object);", "serialize", PhpCapabilityRisk.Low)]
|
||||
[InlineData("json_encode($data);", "json_encode", PhpCapabilityRisk.Low)]
|
||||
[InlineData("json_decode($json);", "json_decode", PhpCapabilityRisk.Low)]
|
||||
[InlineData("igbinary_unserialize($data);", "igbinary_unserialize", PhpCapabilityRisk.High)]
|
||||
public void ScanContent_SerializationFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Serialization && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("public function __wakeup()")]
|
||||
[InlineData("private function __sleep()")]
|
||||
[InlineData("public function __serialize()")]
|
||||
[InlineData("public function __unserialize($data)")]
|
||||
public void ScanContent_SerializationMagicMethods_Detects(string line)
|
||||
{
|
||||
var content = $"<?php\nclass Test {{\n {line} {{ }}\n}}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Serialization);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Crypto Capabilities
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_OpenSslFunctions_DetectsMediumRisk()
|
||||
{
|
||||
var content = @"<?php
|
||||
openssl_encrypt($data, 'AES-256-CBC', $key);
|
||||
openssl_decrypt($encrypted, 'AES-256-CBC', $key);
|
||||
openssl_sign($data, $signature, $privateKey);
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.True(result.Count(e => e.Kind == PhpCapabilityKind.Crypto) >= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_SodiumFunctions_DetectsLowRisk()
|
||||
{
|
||||
var content = @"<?php
|
||||
sodium_crypto_secretbox($message, $nonce, $key);
|
||||
sodium_crypto_box($message, $nonce, $keyPair);
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.All(result.Where(e => e.Kind == PhpCapabilityKind.Crypto && e.Pattern.StartsWith("sodium")),
|
||||
e => Assert.Equal(PhpCapabilityRisk.Low, e.Risk));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("md5($password);", "md5", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("sha1($data);", "sha1", PhpCapabilityRisk.Low)]
|
||||
[InlineData("hash('sha256', $data);", "hash", PhpCapabilityRisk.Low)]
|
||||
[InlineData("password_hash($password, PASSWORD_DEFAULT);", "password_hash", PhpCapabilityRisk.Low)]
|
||||
[InlineData("mcrypt_encrypt(MCRYPT_RIJNDAEL_256, $key, $data, MCRYPT_MODE_CBC);", "mcrypt_encrypt", PhpCapabilityRisk.High)]
|
||||
public void ScanContent_HashFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Crypto && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Database Capabilities
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_MysqliFunctions_DetectsDatabase()
|
||||
{
|
||||
var content = @"<?php
|
||||
$conn = mysqli_connect('localhost', 'user', 'pass', 'db');
|
||||
mysqli_query($conn, 'SELECT * FROM users');
|
||||
mysqli_close($conn);
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.True(result.Count(e => e.Kind == PhpCapabilityKind.Database) >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_PdoUsage_DetectsDatabase()
|
||||
{
|
||||
var content = @"<?php
|
||||
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Database && e.FunctionOrPattern == "PDO");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_PostgresFunctions_DetectsDatabase()
|
||||
{
|
||||
var content = @"<?php
|
||||
$conn = pg_connect('host=localhost dbname=test');
|
||||
pg_query($conn, 'SELECT * FROM users');
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.True(result.Count(e => e.Kind == PhpCapabilityKind.Database) >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_RawSqlQuery_DetectsHighRisk()
|
||||
{
|
||||
var content = "<?php\n$query = \"SELECT * FROM users WHERE id = $id\";";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Database && e.FunctionOrPattern == "raw_sql_query");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Upload Capabilities
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_FilesSuperglobal_DetectsHighRisk()
|
||||
{
|
||||
var content = "<?php\n$file = $_FILES['upload'];";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Upload && e.FunctionOrPattern == "$_FILES");
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_MoveUploadedFile_DetectsHighRisk()
|
||||
{
|
||||
var content = "<?php\nmove_uploaded_file($_FILES['file']['tmp_name'], '/uploads/file.txt');";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Upload && e.FunctionOrPattern == "move_uploaded_file");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stream Wrapper Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("php://input", PhpCapabilityRisk.High)]
|
||||
[InlineData("php://filter", PhpCapabilityRisk.Critical)]
|
||||
[InlineData("php://memory", PhpCapabilityRisk.Low)]
|
||||
[InlineData("data://", PhpCapabilityRisk.High)]
|
||||
[InlineData("phar://", PhpCapabilityRisk.Critical)]
|
||||
[InlineData("zip://", PhpCapabilityRisk.High)]
|
||||
[InlineData("expect://", PhpCapabilityRisk.Critical)]
|
||||
public void ScanContent_StreamWrappers_DetectsAppropriateRisk(string wrapper, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n$data = file_get_contents('{wrapper}data');";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.StreamWrapper && e.FunctionOrPattern == wrapper);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_StreamWrapperRegister_DetectsHighRisk()
|
||||
{
|
||||
var content = "<?php\nstream_wrapper_register('myproto', 'MyProtocolHandler');";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.StreamWrapper && e.FunctionOrPattern == "stream_wrapper_register");
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dynamic Code Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("eval($code);", "eval")]
|
||||
[InlineData("create_function('$a', 'return $a * 2;');", "create_function")]
|
||||
[InlineData("assert($condition);", "assert")]
|
||||
public void ScanContent_DynamicCodeExecution_DetectsCriticalRisk(string line, string expectedFunction)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.DynamicCode && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.Critical, evidence.Risk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("call_user_func($callback, $arg);", "call_user_func")]
|
||||
[InlineData("call_user_func_array($callback, $args);", "call_user_func_array")]
|
||||
[InlineData("preg_replace('/pattern/e', 'code', $subject);", "preg_replace")]
|
||||
public void ScanContent_DynamicCodeHigh_DetectsHighRisk(string line, string expectedFunction)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.DynamicCode && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_VariableFunction_DetectsHighRisk()
|
||||
{
|
||||
var content = "<?php\n$func = 'system';\n$func('ls');";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.DynamicCode && e.FunctionOrPattern == "variable_function");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reflection Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("new ReflectionClass('MyClass');", "ReflectionClass")]
|
||||
[InlineData("new ReflectionMethod($obj, 'method');", "ReflectionMethod")]
|
||||
[InlineData("new ReflectionFunction('func');", "ReflectionFunction")]
|
||||
[InlineData("new ReflectionProperty($obj, 'prop');", "ReflectionProperty")]
|
||||
public void ScanContent_ReflectionClasses_DetectsMediumRisk(string line, string expectedClass)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Reflection && e.FunctionOrPattern == expectedClass);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.Medium, evidence.Risk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("get_defined_functions();", "get_defined_functions")]
|
||||
[InlineData("get_defined_vars();", "get_defined_vars")]
|
||||
[InlineData("get_loaded_extensions();", "get_loaded_extensions")]
|
||||
public void ScanContent_IntrospectionFunctions_Detects(string line, string expectedFunction)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Reflection && e.FunctionOrPattern == expectedFunction);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Output Control Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("header('Location: /redirect');", "header", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("setcookie('session', $value);", "setcookie", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("ob_start();", "ob_start", PhpCapabilityRisk.Low)]
|
||||
public void ScanContent_OutputFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.OutputControl && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Session Capabilities
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_SessionSuperglobal_DetectsMediumRisk()
|
||||
{
|
||||
var content = "<?php\n$_SESSION['user'] = $userId;";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Session && e.FunctionOrPattern == "$_SESSION");
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(PhpCapabilityRisk.Medium, evidence.Risk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("session_start();", "session_start", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("session_destroy();", "session_destroy", PhpCapabilityRisk.Low)]
|
||||
[InlineData("session_set_save_handler($handler);", "session_set_save_handler", PhpCapabilityRisk.High)]
|
||||
public void ScanContent_SessionFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Session && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Capabilities
|
||||
|
||||
[Theory]
|
||||
[InlineData("ini_set('display_errors', 1);", "ini_set", PhpCapabilityRisk.High)]
|
||||
[InlineData("ini_get('memory_limit');", "ini_get", PhpCapabilityRisk.Low)]
|
||||
[InlineData("phpinfo();", "phpinfo", PhpCapabilityRisk.High)]
|
||||
[InlineData("error_reporting(E_ALL);", "error_reporting", PhpCapabilityRisk.Medium)]
|
||||
[InlineData("set_error_handler($handler);", "set_error_handler", PhpCapabilityRisk.Medium)]
|
||||
public void ScanContent_ErrorFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
|
||||
{
|
||||
var content = $"<?php\n{line}";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.ErrorHandling && e.FunctionOrPattern == expectedFunction);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(expectedRisk, evidence.Risk);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases and Integration
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_EmptyContent_ReturnsEmpty()
|
||||
{
|
||||
var result = PhpCapabilityScanner.ScanContent("", "test.php");
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_NullContent_ReturnsEmpty()
|
||||
{
|
||||
var result = PhpCapabilityScanner.ScanContent(null!, "test.php");
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_WhitespaceContent_ReturnsEmpty()
|
||||
{
|
||||
var result = PhpCapabilityScanner.ScanContent(" \n\t ", "test.php");
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_MultipleCapabilities_DetectsAll()
|
||||
{
|
||||
var content = @"<?php
|
||||
exec('ls');
|
||||
$data = file_get_contents('data.txt');
|
||||
$conn = mysqli_connect('localhost', 'user', 'pass');
|
||||
$_SESSION['user'] = $user;
|
||||
unserialize($input);
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.True(result.Count >= 5);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Exec);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Filesystem);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Database);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Session);
|
||||
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Serialization);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_MultiLineComment_SkipsCommentedCode()
|
||||
{
|
||||
var content = @"<?php
|
||||
/*
|
||||
exec('ls');
|
||||
unserialize($data);
|
||||
*/
|
||||
echo 'Hello';
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_CaseInsensitive_DetectsFunctions()
|
||||
{
|
||||
var content = @"<?php
|
||||
EXEC('ls');
|
||||
Shell_Exec('whoami');
|
||||
UNSERIALIZE($data);
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, e => e.FunctionOrPattern == "exec");
|
||||
Assert.Contains(result, e => e.FunctionOrPattern == "shell_exec");
|
||||
Assert.Contains(result, e => e.FunctionOrPattern == "unserialize");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_CorrectLineNumbers_ReportsAccurately()
|
||||
{
|
||||
var content = @"<?php
|
||||
// Line 2
|
||||
// Line 3
|
||||
exec('ls'); // Line 4
|
||||
// Line 5
|
||||
shell_exec('pwd'); // Line 6
|
||||
";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var execEvidence = result.FirstOrDefault(e => e.FunctionOrPattern == "exec");
|
||||
var shellExecEvidence = result.FirstOrDefault(e => e.FunctionOrPattern == "shell_exec");
|
||||
|
||||
Assert.NotNull(execEvidence);
|
||||
Assert.NotNull(shellExecEvidence);
|
||||
Assert.Equal(4, execEvidence.SourceLine);
|
||||
Assert.Equal(6, shellExecEvidence.SourceLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_SnippetTruncation_TruncatesLongLines()
|
||||
{
|
||||
var longLine = new string('x', 200);
|
||||
var content = $"<?php\nexec('{longLine}');";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
var evidence = result.First();
|
||||
Assert.NotNull(evidence.Snippet);
|
||||
Assert.True(evidence.Snippet.Length <= 153); // 150 + "..."
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanContent_SourceFilePreserved_InEvidence()
|
||||
{
|
||||
var content = "<?php\nexec('ls');";
|
||||
var result = PhpCapabilityScanner.ScanContent(content, "src/controllers/AdminController.php");
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.All(result, e => Assert.Equal("src/controllers/AdminController.php", e.SourceFile));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
|
||||
|
||||
public sealed class PhpComposerManifestReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public PhpComposerManifestReaderTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"manifest-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
#region PhpComposerManifestReader Tests
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NullPath_ReturnsNull()
|
||||
{
|
||||
var result = await PhpComposerManifestReader.LoadAsync(null!, CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_EmptyPath_ReturnsNull()
|
||||
{
|
||||
var result = await PhpComposerManifestReader.LoadAsync("", CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NonExistentDirectory_ReturnsNull()
|
||||
{
|
||||
var result = await PhpComposerManifestReader.LoadAsync("/nonexistent/path", CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NoComposerJson_ReturnsNull()
|
||||
{
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_InvalidJson_ReturnsNull()
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), "{ invalid json }");
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ValidManifest_ParsesBasicFields()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""description"": ""A test package"",
|
||||
""type"": ""library"",
|
||||
""version"": ""1.2.3"",
|
||||
""license"": ""MIT""
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("vendor/package", result.Name);
|
||||
Assert.Equal("A test package", result.Description);
|
||||
Assert.Equal("library", result.Type);
|
||||
Assert.Equal("1.2.3", result.Version);
|
||||
Assert.Equal("MIT", result.License);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesLicenseArray()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""license"": [""MIT"", ""Apache-2.0""]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("MIT OR Apache-2.0", result.License);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesAuthors()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""authors"": [
|
||||
{ ""name"": ""John Doe"", ""email"": ""john@example.com"" },
|
||||
{ ""name"": ""Jane Smith"" }
|
||||
]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Authors.Count);
|
||||
Assert.Contains("John Doe <john@example.com>", result.Authors);
|
||||
Assert.Contains("Jane Smith", result.Authors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesRequireDependencies()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""require"": {
|
||||
""php"": "">=8.1"",
|
||||
""ext-json"": ""*"",
|
||||
""monolog/monolog"": ""^3.0""
|
||||
}
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Require.Count);
|
||||
Assert.Equal(">=8.1", result.Require["php"]);
|
||||
Assert.Equal("*", result.Require["ext-json"]);
|
||||
Assert.Equal("^3.0", result.Require["monolog/monolog"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesRequireDevDependencies()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""require-dev"": {
|
||||
""phpunit/phpunit"": ""^10.0"",
|
||||
""phpstan/phpstan"": ""^1.0""
|
||||
}
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.RequireDev.Count);
|
||||
Assert.Equal("^10.0", result.RequireDev["phpunit/phpunit"]);
|
||||
Assert.Equal("^1.0", result.RequireDev["phpstan/phpstan"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesAutoloadPsr4()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""autoload"": {
|
||||
""psr-4"": {
|
||||
""Vendor\\Package\\"": ""src/""
|
||||
}
|
||||
}
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Autoload.Psr4);
|
||||
Assert.Contains("Vendor\\Package\\->src/", result.Autoload.Psr4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesAutoloadClassmap()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""autoload"": {
|
||||
""classmap"": [""lib/"", ""src/Legacy/""]
|
||||
}
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Autoload.Classmap.Count);
|
||||
Assert.Contains("lib/", result.Autoload.Classmap);
|
||||
Assert.Contains("src/Legacy/", result.Autoload.Classmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesAutoloadFiles()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""autoload"": {
|
||||
""files"": [""src/helpers.php"", ""src/functions.php""]
|
||||
}
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Autoload.Files.Count);
|
||||
Assert.Contains("src/helpers.php", result.Autoload.Files);
|
||||
Assert.Contains("src/functions.php", result.Autoload.Files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesScripts()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""scripts"": {
|
||||
""test"": ""phpunit"",
|
||||
""lint"": ""phpstan analyse""
|
||||
}
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Scripts.Count);
|
||||
Assert.Equal("phpunit", result.Scripts["test"]);
|
||||
Assert.Equal("phpstan analyse", result.Scripts["lint"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesScriptsArray()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""scripts"": {
|
||||
""check"": [""phpstan analyse"", ""phpunit""]
|
||||
}
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Scripts);
|
||||
Assert.Contains("phpstan analyse", result.Scripts["check"]);
|
||||
Assert.Contains("phpunit", result.Scripts["check"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesBin()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""bin"": [""bin/console"", ""bin/migrate""]
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Bin.Count);
|
||||
Assert.Equal("bin/console", result.Bin["console"]);
|
||||
Assert.Equal("bin/migrate", result.Bin["migrate"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesMinimumStability()
|
||||
{
|
||||
var manifest = @"{
|
||||
""name"": ""vendor/package"",
|
||||
""minimum-stability"": ""dev""
|
||||
}";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("dev", result.MinimumStability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ComputesSha256()
|
||||
{
|
||||
var manifest = @"{ ""name"": ""vendor/package"" }";
|
||||
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Sha256);
|
||||
Assert.Equal(64, result.Sha256.Length);
|
||||
Assert.True(result.Sha256.All(c => char.IsAsciiHexDigitLower(c)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SetsManifestPath()
|
||||
{
|
||||
var manifest = @"{ ""name"": ""vendor/package"" }";
|
||||
var manifestPath = Path.Combine(_testDir, "composer.json");
|
||||
await File.WriteAllTextAsync(manifestPath, manifest);
|
||||
|
||||
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(manifestPath, result.ManifestPath);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpComposerManifest Tests
|
||||
|
||||
[Fact]
|
||||
public void RequiredPhpVersion_ReturnsPhpConstraint()
|
||||
{
|
||||
var manifest = new PhpComposerManifest(
|
||||
"/test/composer.json",
|
||||
"vendor/package",
|
||||
null, null, null, null,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string> { { "php", ">=8.1" } },
|
||||
new Dictionary<string, string>(),
|
||||
ComposerAutoloadData.Empty,
|
||||
ComposerAutoloadData.Empty,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
null, null);
|
||||
|
||||
Assert.Equal(">=8.1", manifest.RequiredPhpVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredPhpVersion_ReturnsNullWhenNotSpecified()
|
||||
{
|
||||
var manifest = new PhpComposerManifest(
|
||||
"/test/composer.json",
|
||||
"vendor/package",
|
||||
null, null, null, null,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
ComposerAutoloadData.Empty,
|
||||
ComposerAutoloadData.Empty,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
null, null);
|
||||
|
||||
Assert.Null(manifest.RequiredPhpVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredExtensions_ReturnsExtensionsList()
|
||||
{
|
||||
var manifest = new PhpComposerManifest(
|
||||
"/test/composer.json",
|
||||
"vendor/package",
|
||||
null, null, null, null,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "ext-json", "*" },
|
||||
{ "ext-mbstring", "*" },
|
||||
{ "ext-curl", "*" },
|
||||
{ "monolog/monolog", "^3.0" }
|
||||
},
|
||||
new Dictionary<string, string>(),
|
||||
ComposerAutoloadData.Empty,
|
||||
ComposerAutoloadData.Empty,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
null, null);
|
||||
|
||||
var extensions = manifest.RequiredExtensions.ToList();
|
||||
|
||||
Assert.Equal(3, extensions.Count);
|
||||
Assert.Contains("json", extensions);
|
||||
Assert.Contains("mbstring", extensions);
|
||||
Assert.Contains("curl", extensions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMetadata_IncludesAllFields()
|
||||
{
|
||||
var manifest = new PhpComposerManifest(
|
||||
"/test/composer.json",
|
||||
"vendor/package",
|
||||
"Test package",
|
||||
"library",
|
||||
"1.0.0",
|
||||
"MIT",
|
||||
new[] { "Author" },
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "php", ">=8.1" },
|
||||
{ "ext-json", "*" },
|
||||
{ "monolog/monolog", "^3.0" }
|
||||
},
|
||||
new Dictionary<string, string> { { "phpunit/phpunit", "^10.0" } },
|
||||
ComposerAutoloadData.Empty,
|
||||
ComposerAutoloadData.Empty,
|
||||
new Dictionary<string, string>(),
|
||||
new Dictionary<string, string>(),
|
||||
null,
|
||||
"abc123def456");
|
||||
|
||||
var metadata = manifest.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("vendor/package", metadata["composer.manifest.name"]);
|
||||
Assert.Equal("library", metadata["composer.manifest.type"]);
|
||||
Assert.Equal("MIT", metadata["composer.manifest.license"]);
|
||||
Assert.Equal(">=8.1", metadata["composer.manifest.php_version"]);
|
||||
Assert.Equal("json", metadata["composer.manifest.extensions"]);
|
||||
Assert.Equal("3", metadata["composer.manifest.require_count"]);
|
||||
Assert.Equal("1", metadata["composer.manifest.require_dev_count"]);
|
||||
Assert.Equal("abc123def456", metadata["composer.manifest.sha256"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_HasNullValues()
|
||||
{
|
||||
var empty = PhpComposerManifest.Empty;
|
||||
|
||||
Assert.Equal(string.Empty, empty.ManifestPath);
|
||||
Assert.Null(empty.Name);
|
||||
Assert.Null(empty.Description);
|
||||
Assert.Null(empty.Type);
|
||||
Assert.Null(empty.Version);
|
||||
Assert.Null(empty.License);
|
||||
Assert.Empty(empty.Authors);
|
||||
Assert.Empty(empty.Require);
|
||||
Assert.Empty(empty.RequireDev);
|
||||
Assert.Null(empty.MinimumStability);
|
||||
Assert.Null(empty.Sha256);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ComposerAutoloadData Tests
|
||||
|
||||
[Fact]
|
||||
public void ComposerAutoloadData_Empty_HasEmptyCollections()
|
||||
{
|
||||
var empty = ComposerAutoloadData.Empty;
|
||||
|
||||
Assert.Empty(empty.Psr4);
|
||||
Assert.Empty(empty.Classmap);
|
||||
Assert.Empty(empty.Files);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
|
||||
|
||||
public sealed class PhpExtensionScannerTests
|
||||
{
|
||||
#region PhpExtension Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpExtension_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var extension = new PhpExtension(
|
||||
"pdo_mysql",
|
||||
"8.2.0",
|
||||
"/usr/lib/php/extensions/pdo_mysql.so",
|
||||
PhpExtensionSource.PhpIni,
|
||||
false,
|
||||
PhpExtensionCategory.Database);
|
||||
|
||||
Assert.Equal("pdo_mysql", extension.Name);
|
||||
Assert.Equal("8.2.0", extension.Version);
|
||||
Assert.Equal("/usr/lib/php/extensions/pdo_mysql.so", extension.LibraryPath);
|
||||
Assert.Equal(PhpExtensionSource.PhpIni, extension.Source);
|
||||
Assert.False(extension.IsBundled);
|
||||
Assert.Equal(PhpExtensionCategory.Database, extension.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpExtension_CreateMetadata_IncludesAllFields()
|
||||
{
|
||||
var extension = new PhpExtension(
|
||||
"openssl",
|
||||
"3.0.0",
|
||||
"/usr/lib/php/openssl.so",
|
||||
PhpExtensionSource.ConfD,
|
||||
false,
|
||||
PhpExtensionCategory.Crypto);
|
||||
|
||||
var metadata = extension.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("openssl", metadata["extension.name"]);
|
||||
Assert.Equal("3.0.0", metadata["extension.version"]);
|
||||
Assert.Equal("/usr/lib/php/openssl.so", metadata["extension.library"]);
|
||||
Assert.Equal("confd", metadata["extension.source"]);
|
||||
Assert.Equal("false", metadata["extension.bundled"]);
|
||||
Assert.Equal("crypto", metadata["extension.category"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpExtension_BundledExtension_MarkedCorrectly()
|
||||
{
|
||||
var extension = new PhpExtension(
|
||||
"json",
|
||||
null,
|
||||
null,
|
||||
PhpExtensionSource.Bundled,
|
||||
true,
|
||||
PhpExtensionCategory.Core);
|
||||
|
||||
Assert.True(extension.IsBundled);
|
||||
Assert.Equal(PhpExtensionSource.Bundled, extension.Source);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpExtensionSource Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpExtensionSource_HasExpectedValues()
|
||||
{
|
||||
Assert.Equal(0, (int)PhpExtensionSource.PhpIni);
|
||||
Assert.Equal(1, (int)PhpExtensionSource.ConfD);
|
||||
Assert.Equal(2, (int)PhpExtensionSource.Bundled);
|
||||
Assert.Equal(3, (int)PhpExtensionSource.Container);
|
||||
Assert.Equal(4, (int)PhpExtensionSource.UsageDetected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpExtensionCategory Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpExtensionCategory_HasExpectedValues()
|
||||
{
|
||||
Assert.Equal(0, (int)PhpExtensionCategory.Core);
|
||||
Assert.Equal(1, (int)PhpExtensionCategory.Database);
|
||||
Assert.Equal(2, (int)PhpExtensionCategory.Crypto);
|
||||
Assert.Equal(3, (int)PhpExtensionCategory.Image);
|
||||
Assert.Equal(4, (int)PhpExtensionCategory.Compression);
|
||||
Assert.Equal(5, (int)PhpExtensionCategory.Xml);
|
||||
Assert.Equal(6, (int)PhpExtensionCategory.Cache);
|
||||
Assert.Equal(7, (int)PhpExtensionCategory.Debug);
|
||||
Assert.Equal(8, (int)PhpExtensionCategory.Network);
|
||||
Assert.Equal(9, (int)PhpExtensionCategory.Text);
|
||||
Assert.Equal(10, (int)PhpExtensionCategory.Other);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpEnvironmentSettings Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpEnvironmentSettings_Empty_HasDefaults()
|
||||
{
|
||||
var settings = PhpEnvironmentSettings.Empty;
|
||||
|
||||
Assert.Empty(settings.Extensions);
|
||||
Assert.NotNull(settings.Security);
|
||||
Assert.NotNull(settings.Upload);
|
||||
Assert.NotNull(settings.Session);
|
||||
Assert.NotNull(settings.Error);
|
||||
Assert.NotNull(settings.Limits);
|
||||
Assert.Empty(settings.WebServerSettings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpEnvironmentSettings_HasSettings_TrueWithExtensions()
|
||||
{
|
||||
var extensions = new[] { new PhpExtension("pdo", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Database) };
|
||||
var settings = new PhpEnvironmentSettings(
|
||||
extensions,
|
||||
PhpSecuritySettings.Default,
|
||||
PhpUploadSettings.Default,
|
||||
PhpSessionSettings.Default,
|
||||
PhpErrorSettings.Default,
|
||||
PhpResourceLimits.Default,
|
||||
new Dictionary<string, string>());
|
||||
|
||||
Assert.True(settings.HasSettings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpEnvironmentSettings_CreateMetadata_IncludesExtensionCount()
|
||||
{
|
||||
var extensions = new[]
|
||||
{
|
||||
new PhpExtension("pdo", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Database),
|
||||
new PhpExtension("openssl", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Crypto),
|
||||
new PhpExtension("gd", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Image)
|
||||
};
|
||||
|
||||
var settings = new PhpEnvironmentSettings(
|
||||
extensions,
|
||||
PhpSecuritySettings.Default,
|
||||
PhpUploadSettings.Default,
|
||||
PhpSessionSettings.Default,
|
||||
PhpErrorSettings.Default,
|
||||
PhpResourceLimits.Default,
|
||||
new Dictionary<string, string>());
|
||||
|
||||
var metadata = settings.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("3", metadata["env.extension_count"]);
|
||||
Assert.Equal("1", metadata["env.extensions_database"]);
|
||||
Assert.Equal("1", metadata["env.extensions_crypto"]);
|
||||
Assert.Equal("1", metadata["env.extensions_image"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpSecuritySettings Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpSecuritySettings_Default_HasExpectedValues()
|
||||
{
|
||||
var security = PhpSecuritySettings.Default;
|
||||
|
||||
Assert.Empty(security.DisabledFunctions);
|
||||
Assert.Empty(security.DisabledClasses);
|
||||
Assert.False(security.OpenBasedir);
|
||||
Assert.Null(security.OpenBasedirValue);
|
||||
Assert.True(security.AllowUrlFopen);
|
||||
Assert.False(security.AllowUrlInclude);
|
||||
Assert.True(security.ExposePhp);
|
||||
Assert.False(security.RegisterGlobals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpSecuritySettings_CreateMetadata_IncludesDisabledFunctions()
|
||||
{
|
||||
var security = new PhpSecuritySettings(
|
||||
new[] { "exec", "shell_exec", "system", "passthru" },
|
||||
new[] { "Directory" },
|
||||
true,
|
||||
"/var/www",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false);
|
||||
|
||||
var metadata = security.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("4", metadata["security.disabled_functions_count"]);
|
||||
Assert.Contains("exec", metadata["security.disabled_functions"]);
|
||||
Assert.Contains("shell_exec", metadata["security.disabled_functions"]);
|
||||
Assert.Equal("1", metadata["security.disabled_classes_count"]);
|
||||
Assert.Equal("true", metadata["security.open_basedir"]);
|
||||
Assert.Equal("false", metadata["security.allow_url_fopen"]);
|
||||
Assert.Equal("false", metadata["security.allow_url_include"]);
|
||||
Assert.Equal("false", metadata["security.expose_php"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpSecuritySettings_DangerousConfiguration_Detectable()
|
||||
{
|
||||
var security = new PhpSecuritySettings(
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<string>(),
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
true, // allow_url_include is dangerous!
|
||||
true,
|
||||
false);
|
||||
|
||||
Assert.True(security.AllowUrlInclude);
|
||||
Assert.True(security.AllowUrlFopen);
|
||||
Assert.False(security.OpenBasedir);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpUploadSettings Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpUploadSettings_Default_HasExpectedValues()
|
||||
{
|
||||
var upload = PhpUploadSettings.Default;
|
||||
|
||||
Assert.True(upload.FileUploads);
|
||||
Assert.Equal("2M", upload.MaxFileSize);
|
||||
Assert.Equal("8M", upload.MaxPostSize);
|
||||
Assert.Equal(20, upload.MaxFileUploads);
|
||||
Assert.Null(upload.UploadTmpDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpUploadSettings_CreateMetadata_IncludesAllFields()
|
||||
{
|
||||
var upload = new PhpUploadSettings(
|
||||
true,
|
||||
"64M",
|
||||
"128M",
|
||||
50,
|
||||
"/tmp/uploads");
|
||||
|
||||
var metadata = upload.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("true", metadata["upload.enabled"]);
|
||||
Assert.Equal("64M", metadata["upload.max_file_size"]);
|
||||
Assert.Equal("128M", metadata["upload.max_post_size"]);
|
||||
Assert.Equal("50", metadata["upload.max_files"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpUploadSettings_DisabledUploads()
|
||||
{
|
||||
var upload = new PhpUploadSettings(false, null, null, 0, null);
|
||||
|
||||
Assert.False(upload.FileUploads);
|
||||
Assert.Equal(0, upload.MaxFileUploads);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpSessionSettings Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpSessionSettings_Default_HasExpectedValues()
|
||||
{
|
||||
var session = PhpSessionSettings.Default;
|
||||
|
||||
Assert.Equal("files", session.SaveHandler);
|
||||
Assert.Null(session.SavePath);
|
||||
Assert.False(session.CookieHttponly);
|
||||
Assert.False(session.CookieSecure);
|
||||
Assert.Null(session.CookieSamesite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpSessionSettings_CreateMetadata_IncludesAllFields()
|
||||
{
|
||||
var session = new PhpSessionSettings(
|
||||
"redis",
|
||||
"tcp://localhost:6379",
|
||||
true,
|
||||
true,
|
||||
"Strict");
|
||||
|
||||
var metadata = session.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("redis", metadata["session.save_handler"]);
|
||||
Assert.Equal("true", metadata["session.cookie_httponly"]);
|
||||
Assert.Equal("true", metadata["session.cookie_secure"]);
|
||||
Assert.Equal("Strict", metadata["session.cookie_samesite"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpSessionSettings_SecureConfiguration()
|
||||
{
|
||||
var session = new PhpSessionSettings(
|
||||
"files",
|
||||
"/var/lib/php/sessions",
|
||||
true,
|
||||
true,
|
||||
"Lax");
|
||||
|
||||
Assert.True(session.CookieHttponly);
|
||||
Assert.True(session.CookieSecure);
|
||||
Assert.Equal("Lax", session.CookieSamesite);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpErrorSettings Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpErrorSettings_Default_HasExpectedValues()
|
||||
{
|
||||
var error = PhpErrorSettings.Default;
|
||||
|
||||
Assert.False(error.DisplayErrors);
|
||||
Assert.False(error.DisplayStartupErrors);
|
||||
Assert.True(error.LogErrors);
|
||||
Assert.Equal("E_ALL", error.ErrorReporting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpErrorSettings_CreateMetadata_IncludesAllFields()
|
||||
{
|
||||
var error = new PhpErrorSettings(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"E_ALL & ~E_NOTICE");
|
||||
|
||||
var metadata = error.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("true", metadata["error.display_errors"]);
|
||||
Assert.Equal("true", metadata["error.display_startup_errors"]);
|
||||
Assert.Equal("false", metadata["error.log_errors"]);
|
||||
Assert.Equal("E_ALL & ~E_NOTICE", metadata["error.error_reporting"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpErrorSettings_ProductionConfiguration()
|
||||
{
|
||||
var error = new PhpErrorSettings(false, false, true, "E_ALL");
|
||||
|
||||
Assert.False(error.DisplayErrors);
|
||||
Assert.False(error.DisplayStartupErrors);
|
||||
Assert.True(error.LogErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpErrorSettings_DevelopmentConfiguration()
|
||||
{
|
||||
var error = new PhpErrorSettings(true, true, true, "E_ALL");
|
||||
|
||||
Assert.True(error.DisplayErrors);
|
||||
Assert.True(error.DisplayStartupErrors);
|
||||
Assert.True(error.LogErrors);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpResourceLimits Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpResourceLimits_Default_HasExpectedValues()
|
||||
{
|
||||
var limits = PhpResourceLimits.Default;
|
||||
|
||||
Assert.Equal("128M", limits.MemoryLimit);
|
||||
Assert.Equal(30, limits.MaxExecutionTime);
|
||||
Assert.Equal(60, limits.MaxInputTime);
|
||||
Assert.Equal("1000", limits.MaxInputVars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpResourceLimits_CreateMetadata_IncludesAllFields()
|
||||
{
|
||||
var limits = new PhpResourceLimits(
|
||||
"512M",
|
||||
120,
|
||||
180,
|
||||
"5000");
|
||||
|
||||
var metadata = limits.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("512M", metadata["limits.memory_limit"]);
|
||||
Assert.Equal("120", metadata["limits.max_execution_time"]);
|
||||
Assert.Equal("180", metadata["limits.max_input_time"]);
|
||||
Assert.Equal("5000", metadata["limits.max_input_vars"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpResourceLimits_HighPerformanceConfiguration()
|
||||
{
|
||||
var limits = new PhpResourceLimits("2G", 300, 300, "10000");
|
||||
|
||||
Assert.Equal("2G", limits.MemoryLimit);
|
||||
Assert.Equal(300, limits.MaxExecutionTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpResourceLimits_RestrictedConfiguration()
|
||||
{
|
||||
var limits = new PhpResourceLimits("64M", 10, 10, "500");
|
||||
|
||||
Assert.Equal("64M", limits.MemoryLimit);
|
||||
Assert.Equal(10, limits.MaxExecutionTime);
|
||||
Assert.Equal(10, limits.MaxInputTime);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
|
||||
|
||||
public sealed class PhpFrameworkSurfaceScannerTests
|
||||
{
|
||||
#region PhpFrameworkSurface Tests
|
||||
|
||||
[Fact]
|
||||
public void Empty_ReturnsEmptySurface()
|
||||
{
|
||||
var surface = PhpFrameworkSurface.Empty;
|
||||
|
||||
Assert.Empty(surface.Routes);
|
||||
Assert.Empty(surface.Controllers);
|
||||
Assert.Empty(surface.Middlewares);
|
||||
Assert.Empty(surface.CliCommands);
|
||||
Assert.Empty(surface.CronJobs);
|
||||
Assert.Empty(surface.EventListeners);
|
||||
Assert.False(surface.HasSurface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSurface_TrueWhenRoutesPresent()
|
||||
{
|
||||
var routes = new[] { CreateRoute("/api/users", "GET") };
|
||||
var surface = new PhpFrameworkSurface(
|
||||
routes,
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
Assert.True(surface.HasSurface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSurface_TrueWhenControllersPresent()
|
||||
{
|
||||
var controllers = new[] { new PhpController("UserController", "App\\Http\\Controllers", "app/Http/Controllers/UserController.php", new[] { "index", "show" }, true) };
|
||||
var surface = new PhpFrameworkSurface(
|
||||
Array.Empty<PhpRoute>(),
|
||||
controllers,
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
Assert.True(surface.HasSurface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSurface_TrueWhenMiddlewaresPresent()
|
||||
{
|
||||
var middlewares = new[] { new PhpMiddleware("AuthMiddleware", "App\\Http\\Middleware", "app/Http/Middleware/AuthMiddleware.php", PhpMiddlewareKind.Auth) };
|
||||
var surface = new PhpFrameworkSurface(
|
||||
Array.Empty<PhpRoute>(),
|
||||
Array.Empty<PhpController>(),
|
||||
middlewares,
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
Assert.True(surface.HasSurface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSurface_TrueWhenCliCommandsPresent()
|
||||
{
|
||||
var commands = new[] { new PhpCliCommand("app:sync", "Sync data", "SyncCommand", "app/Console/Commands/SyncCommand.php") };
|
||||
var surface = new PhpFrameworkSurface(
|
||||
Array.Empty<PhpRoute>(),
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
commands,
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
Assert.True(surface.HasSurface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSurface_TrueWhenCronJobsPresent()
|
||||
{
|
||||
var cronJobs = new[] { new PhpCronJob("hourly", "ReportCommand", "Generate hourly report", "app/Console/Kernel.php") };
|
||||
var surface = new PhpFrameworkSurface(
|
||||
Array.Empty<PhpRoute>(),
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
cronJobs,
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
Assert.True(surface.HasSurface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSurface_TrueWhenEventListenersPresent()
|
||||
{
|
||||
var listeners = new[] { new PhpEventListener("UserRegistered", "SendWelcomeEmail", 0, "app/Providers/EventServiceProvider.php") };
|
||||
var surface = new PhpFrameworkSurface(
|
||||
Array.Empty<PhpRoute>(),
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
listeners);
|
||||
|
||||
Assert.True(surface.HasSurface);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMetadata_IncludesAllCounts()
|
||||
{
|
||||
var routes = new[] { CreateRoute("/api/users", "GET"), CreateRoute("/api/users/{id}", "GET", true) };
|
||||
var controllers = new[] { new PhpController("UserController", null, "UserController.php", Array.Empty<string>(), false) };
|
||||
var middlewares = new[] { new PhpMiddleware("AuthMiddleware", null, "AuthMiddleware.php", PhpMiddlewareKind.Auth) };
|
||||
var commands = new[] { new PhpCliCommand("app:sync", null, "SyncCommand", "SyncCommand.php") };
|
||||
var cronJobs = new[] { new PhpCronJob("hourly", "Report", null, "Kernel.php") };
|
||||
var listeners = new[] { new PhpEventListener("Event", "Handler", 0, "Provider.php") };
|
||||
|
||||
var surface = new PhpFrameworkSurface(routes, controllers, middlewares, commands, cronJobs, listeners);
|
||||
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("2", metadata["surface.route_count"]);
|
||||
Assert.Equal("1", metadata["surface.controller_count"]);
|
||||
Assert.Equal("1", metadata["surface.middleware_count"]);
|
||||
Assert.Equal("1", metadata["surface.cli_command_count"]);
|
||||
Assert.Equal("1", metadata["surface.cron_job_count"]);
|
||||
Assert.Equal("1", metadata["surface.event_listener_count"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMetadata_IncludesHttpMethods()
|
||||
{
|
||||
var routes = new[]
|
||||
{
|
||||
CreateRoute("/users", "GET"),
|
||||
CreateRoute("/users", "POST"),
|
||||
CreateRoute("/users/{id}", "PUT"),
|
||||
CreateRoute("/users/{id}", "DELETE")
|
||||
};
|
||||
|
||||
var surface = new PhpFrameworkSurface(
|
||||
routes,
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Contains("GET", metadata["surface.http_methods"]);
|
||||
Assert.Contains("POST", metadata["surface.http_methods"]);
|
||||
Assert.Contains("PUT", metadata["surface.http_methods"]);
|
||||
Assert.Contains("DELETE", metadata["surface.http_methods"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMetadata_CountsProtectedAndPublicRoutes()
|
||||
{
|
||||
var routes = new[]
|
||||
{
|
||||
CreateRoute("/public", "GET", requiresAuth: false),
|
||||
CreateRoute("/api/users", "GET", requiresAuth: true),
|
||||
CreateRoute("/api/admin", "GET", requiresAuth: true)
|
||||
};
|
||||
|
||||
var surface = new PhpFrameworkSurface(
|
||||
routes,
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("2", metadata["surface.protected_routes"]);
|
||||
Assert.Equal("1", metadata["surface.public_routes"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMetadata_IncludesRoutePatterns()
|
||||
{
|
||||
var routes = new[]
|
||||
{
|
||||
CreateRoute("/api/v1/users", "GET"),
|
||||
CreateRoute("/api/v1/posts", "GET"),
|
||||
CreateRoute("/api/v1/comments", "GET")
|
||||
};
|
||||
|
||||
var surface = new PhpFrameworkSurface(
|
||||
routes,
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
|
||||
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.True(metadata.ContainsKey("surface.route_patterns"));
|
||||
Assert.Contains("/api/v1/users", metadata["surface.route_patterns"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpRoute Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpRoute_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var route = new PhpRoute(
|
||||
"/api/users/{id}",
|
||||
new[] { "GET", "HEAD" },
|
||||
"UserController",
|
||||
"show",
|
||||
"users.show",
|
||||
true,
|
||||
new[] { "auth", "throttle" },
|
||||
"routes/api.php",
|
||||
42);
|
||||
|
||||
Assert.Equal("/api/users/{id}", route.Pattern);
|
||||
Assert.Equal(2, route.Methods.Count);
|
||||
Assert.Contains("GET", route.Methods);
|
||||
Assert.Contains("HEAD", route.Methods);
|
||||
Assert.Equal("UserController", route.Controller);
|
||||
Assert.Equal("show", route.Action);
|
||||
Assert.Equal("users.show", route.Name);
|
||||
Assert.True(route.RequiresAuth);
|
||||
Assert.Equal(2, route.Middlewares.Count);
|
||||
Assert.Equal("routes/api.php", route.SourceFile);
|
||||
Assert.Equal(42, route.SourceLine);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpController Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpController_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var controller = new PhpController(
|
||||
"UserController",
|
||||
"App\\Http\\Controllers",
|
||||
"app/Http/Controllers/UserController.php",
|
||||
new[] { "index", "show", "store", "update", "destroy" },
|
||||
true);
|
||||
|
||||
Assert.Equal("UserController", controller.ClassName);
|
||||
Assert.Equal("App\\Http\\Controllers", controller.Namespace);
|
||||
Assert.Equal("app/Http/Controllers/UserController.php", controller.SourceFile);
|
||||
Assert.Equal(5, controller.Actions.Count);
|
||||
Assert.True(controller.IsApiController);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpController_IsApiController_FalseForWebController()
|
||||
{
|
||||
var controller = new PhpController(
|
||||
"HomeController",
|
||||
"App\\Http\\Controllers",
|
||||
"app/Http/Controllers/HomeController.php",
|
||||
new[] { "index", "about" },
|
||||
false);
|
||||
|
||||
Assert.False(controller.IsApiController);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpMiddleware Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpMiddleware_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var middleware = new PhpMiddleware(
|
||||
"AuthenticateMiddleware",
|
||||
"App\\Http\\Middleware",
|
||||
"app/Http/Middleware/AuthenticateMiddleware.php",
|
||||
PhpMiddlewareKind.Auth);
|
||||
|
||||
Assert.Equal("AuthenticateMiddleware", middleware.ClassName);
|
||||
Assert.Equal("App\\Http\\Middleware", middleware.Namespace);
|
||||
Assert.Equal("app/Http/Middleware/AuthenticateMiddleware.php", middleware.SourceFile);
|
||||
Assert.Equal(PhpMiddlewareKind.Auth, middleware.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpMiddlewareKind_HasExpectedValues()
|
||||
{
|
||||
Assert.Equal(0, (int)PhpMiddlewareKind.General);
|
||||
Assert.Equal(1, (int)PhpMiddlewareKind.Auth);
|
||||
Assert.Equal(2, (int)PhpMiddlewareKind.Cors);
|
||||
Assert.Equal(3, (int)PhpMiddlewareKind.RateLimit);
|
||||
Assert.Equal(4, (int)PhpMiddlewareKind.Logging);
|
||||
Assert.Equal(5, (int)PhpMiddlewareKind.Security);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpCliCommand Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpCliCommand_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var command = new PhpCliCommand(
|
||||
"app:import-data",
|
||||
"Import data from external source",
|
||||
"ImportDataCommand",
|
||||
"app/Console/Commands/ImportDataCommand.php");
|
||||
|
||||
Assert.Equal("app:import-data", command.Name);
|
||||
Assert.Equal("Import data from external source", command.Description);
|
||||
Assert.Equal("ImportDataCommand", command.ClassName);
|
||||
Assert.Equal("app/Console/Commands/ImportDataCommand.php", command.SourceFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpCliCommand_NullDescription_Allowed()
|
||||
{
|
||||
var command = new PhpCliCommand(
|
||||
"app:sync",
|
||||
null,
|
||||
"SyncCommand",
|
||||
"SyncCommand.php");
|
||||
|
||||
Assert.Null(command.Description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpCronJob Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpCronJob_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var cronJob = new PhpCronJob(
|
||||
"daily",
|
||||
"CleanupOldData",
|
||||
"Remove data older than 30 days",
|
||||
"app/Console/Kernel.php");
|
||||
|
||||
Assert.Equal("daily", cronJob.Schedule);
|
||||
Assert.Equal("CleanupOldData", cronJob.Handler);
|
||||
Assert.Equal("Remove data older than 30 days", cronJob.Description);
|
||||
Assert.Equal("app/Console/Kernel.php", cronJob.SourceFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpCronJob_VariousSchedules()
|
||||
{
|
||||
var jobs = new[]
|
||||
{
|
||||
new PhpCronJob("hourly", "HourlyJob", null, "Kernel.php"),
|
||||
new PhpCronJob("daily", "DailyJob", null, "Kernel.php"),
|
||||
new PhpCronJob("weekly", "WeeklyJob", null, "Kernel.php"),
|
||||
new PhpCronJob("monthly", "MonthlyJob", null, "Kernel.php"),
|
||||
new PhpCronJob("everyMinute", "MinuteJob", null, "Kernel.php"),
|
||||
new PhpCronJob("everyFiveMinutes", "FiveMinJob", null, "Kernel.php")
|
||||
};
|
||||
|
||||
Assert.Equal(6, jobs.Length);
|
||||
Assert.All(jobs, j => Assert.NotNull(j.Schedule));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpEventListener Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpEventListener_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var listener = new PhpEventListener(
|
||||
"App\\Events\\UserRegistered",
|
||||
"App\\Listeners\\SendWelcomeEmail",
|
||||
10,
|
||||
"app/Providers/EventServiceProvider.php");
|
||||
|
||||
Assert.Equal("App\\Events\\UserRegistered", listener.EventName);
|
||||
Assert.Equal("App\\Listeners\\SendWelcomeEmail", listener.Handler);
|
||||
Assert.Equal(10, listener.Priority);
|
||||
Assert.Equal("app/Providers/EventServiceProvider.php", listener.SourceFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpEventListener_DefaultPriority()
|
||||
{
|
||||
var listener = new PhpEventListener(
|
||||
"EventName",
|
||||
"Handler",
|
||||
0,
|
||||
"Provider.php");
|
||||
|
||||
Assert.Equal(0, listener.Priority);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static PhpRoute CreateRoute(string pattern, string method, bool requiresAuth = false)
|
||||
{
|
||||
return new PhpRoute(
|
||||
pattern,
|
||||
new[] { method },
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
requiresAuth,
|
||||
Array.Empty<string>(),
|
||||
"routes/web.php",
|
||||
1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
|
||||
|
||||
public sealed class PhpPharScannerTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public PhpPharScannerTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"phar-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
#region PhpPharScanner Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ScanFileAsync_NonExistentFile_ReturnsNull()
|
||||
{
|
||||
var result = await PhpPharScanner.ScanFileAsync(
|
||||
Path.Combine(_testDir, "nonexistent.phar"),
|
||||
"nonexistent.phar",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanFileAsync_NullPath_ReturnsNull()
|
||||
{
|
||||
var result = await PhpPharScanner.ScanFileAsync(null!, "test.phar", CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanFileAsync_EmptyPath_ReturnsNull()
|
||||
{
|
||||
var result = await PhpPharScanner.ScanFileAsync("", "test.phar", CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanFileAsync_InvalidPharFile_ReturnsNull()
|
||||
{
|
||||
var filePath = Path.Combine(_testDir, "invalid.phar");
|
||||
await File.WriteAllTextAsync(filePath, "This is not a valid PHAR file");
|
||||
|
||||
var result = await PhpPharScanner.ScanFileAsync(filePath, "invalid.phar", CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanFileAsync_MinimalPhar_ParsesStub()
|
||||
{
|
||||
// Create a minimal PHAR structure with __HALT_COMPILER();
|
||||
var stub = "<?php\necho 'Hello';\n__HALT_COMPILER();";
|
||||
var pharContent = CreateMinimalPharBytes(stub);
|
||||
var filePath = Path.Combine(_testDir, "minimal.phar");
|
||||
await File.WriteAllBytesAsync(filePath, pharContent);
|
||||
|
||||
var result = await PhpPharScanner.ScanFileAsync(filePath, "minimal.phar", CancellationToken.None);
|
||||
|
||||
// May return null if manifest parsing fails, but should not throw
|
||||
// The minimal PHAR may not have a valid manifest
|
||||
if (result is not null)
|
||||
{
|
||||
Assert.Contains("__HALT_COMPILER();", result.Stub);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanFileAsync_ComputesSha256()
|
||||
{
|
||||
var stub = "<?php\n__HALT_COMPILER();";
|
||||
var pharContent = CreateMinimalPharBytes(stub);
|
||||
var filePath = Path.Combine(_testDir, "hash.phar");
|
||||
await File.WriteAllBytesAsync(filePath, pharContent);
|
||||
|
||||
var result = await PhpPharScanner.ScanFileAsync(filePath, "hash.phar", CancellationToken.None);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
Assert.NotNull(result.Sha256);
|
||||
Assert.Equal(64, result.Sha256.Length);
|
||||
Assert.True(result.Sha256.All(c => char.IsAsciiHexDigitLower(c)));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpPharArchive Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_Constructor_NormalizesBackslashes()
|
||||
{
|
||||
var archive = new PhpPharArchive(
|
||||
@"C:\path\to\file.phar",
|
||||
@"vendor\file.phar",
|
||||
null,
|
||||
null,
|
||||
Array.Empty<PhpPharEntry>(),
|
||||
null);
|
||||
|
||||
Assert.Equal("C:/path/to/file.phar", archive.FilePath);
|
||||
Assert.Equal("vendor/file.phar", archive.RelativePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_Constructor_RequiresFilePath()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new PhpPharArchive(
|
||||
"",
|
||||
"test.phar",
|
||||
null,
|
||||
null,
|
||||
Array.Empty<PhpPharEntry>(),
|
||||
null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_HasEmbeddedVendor_TrueForVendorPath()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
|
||||
|
||||
Assert.True(archive.HasEmbeddedVendor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_HasEmbeddedVendor_FalseWithoutVendor()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("lib/Helper.php", 100, 80, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
|
||||
|
||||
Assert.False(archive.HasEmbeddedVendor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_HasComposerFiles_TrueForComposerJson()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("composer.json", 500, 400, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
|
||||
|
||||
Assert.True(archive.HasComposerFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_HasComposerFiles_TrueForComposerLock()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("composer.lock", 5000, 4000, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
|
||||
|
||||
Assert.True(archive.HasComposerFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_FileCount_ReturnsCorrectCount()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("file1.php", 100, 80, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("file2.php", 200, 150, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("file3.php", 300, 250, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
|
||||
|
||||
Assert.Equal(3, archive.FileCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_TotalUncompressedSize_SumsCorrectly()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("file1.php", 100, 80, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("file2.php", 200, 150, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("file3.php", 300, 250, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
|
||||
|
||||
Assert.Equal(600, archive.TotalUncompressedSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_CreateMetadata_IncludesBasicInfo()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("composer.json", 200, 150, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, "abc123");
|
||||
var metadata = archive.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("test.phar", metadata["phar.path"]);
|
||||
Assert.Equal("2", metadata["phar.file_count"]);
|
||||
Assert.Equal("300", metadata["phar.total_size"]);
|
||||
Assert.Equal("true", metadata["phar.has_vendor"]);
|
||||
Assert.Equal("true", metadata["phar.has_composer"]);
|
||||
Assert.Equal("abc123", metadata["phar.sha256"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_CreateMetadata_IncludesManifestInfo()
|
||||
{
|
||||
var manifest = new PhpPharManifest(
|
||||
"myapp",
|
||||
"1.2.3",
|
||||
0x1100,
|
||||
PhpPharCompression.GZip,
|
||||
PhpPharSignatureType.Sha256,
|
||||
new Dictionary<string, string>());
|
||||
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", manifest, null, Array.Empty<PhpPharEntry>(), null);
|
||||
var metadata = archive.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("myapp", metadata["phar.alias"]);
|
||||
Assert.Equal("1.2.3", metadata["phar.version"]);
|
||||
Assert.Equal("gzip", metadata["phar.compression"]);
|
||||
Assert.Equal("sha256", metadata["phar.signature_type"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharArchive_CreateMetadata_DetectsAutoloadInStub()
|
||||
{
|
||||
var stubWithAutoload = "<?php\nspl_autoload_register(function($class) {});\n__HALT_COMPILER();";
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, stubWithAutoload, Array.Empty<PhpPharEntry>(), null);
|
||||
var metadata = archive.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("true", metadata["phar.stub_has_autoload"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpPharEntry Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpPharEntry_Extension_ReturnsCorrectExtension()
|
||||
{
|
||||
var entry = new PhpPharEntry("src/Main.php", 100, 80, 0, 0, PhpPharCompression.None, null);
|
||||
|
||||
Assert.Equal("php", entry.Extension);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharEntry_IsPhpFile_TrueForPhpExtension()
|
||||
{
|
||||
var entry = new PhpPharEntry("src/Main.php", 100, 80, 0, 0, PhpPharCompression.None, null);
|
||||
|
||||
Assert.True(entry.IsPhpFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharEntry_IsPhpFile_FalseForOtherExtensions()
|
||||
{
|
||||
var entry = new PhpPharEntry("config/app.json", 100, 80, 0, 0, PhpPharCompression.None, null);
|
||||
|
||||
Assert.False(entry.IsPhpFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharEntry_IsVendorFile_TrueForVendorPath()
|
||||
{
|
||||
var entry = new PhpPharEntry("vendor/monolog/monolog/src/Logger.php", 100, 80, 0, 0, PhpPharCompression.None, null);
|
||||
|
||||
Assert.True(entry.IsVendorFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharEntry_IsVendorFile_FalseForSrcPath()
|
||||
{
|
||||
var entry = new PhpPharEntry("src/Main.php", 100, 80, 0, 0, PhpPharCompression.None, null);
|
||||
|
||||
Assert.False(entry.IsVendorFile);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpPharScanResult Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpPharScanResult_Empty_HasNoContent()
|
||||
{
|
||||
var result = PhpPharScanResult.Empty;
|
||||
|
||||
Assert.Empty(result.Archives);
|
||||
Assert.Empty(result.Usages);
|
||||
Assert.False(result.HasPharContent);
|
||||
Assert.Equal(0, result.TotalArchivedFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharScanResult_HasPharContent_TrueWithArchives()
|
||||
{
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, Array.Empty<PhpPharEntry>(), null);
|
||||
var result = new PhpPharScanResult(new[] { archive }, Array.Empty<PhpPharUsage>());
|
||||
|
||||
Assert.True(result.HasPharContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharScanResult_HasPharContent_TrueWithUsages()
|
||||
{
|
||||
var usage = new PhpPharUsage("src/Main.php", 10, "phar://myapp.phar/src/Helper.php", "myapp.phar/src/Helper.php");
|
||||
var result = new PhpPharScanResult(Array.Empty<PhpPharArchive>(), new[] { usage });
|
||||
|
||||
Assert.True(result.HasPharContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharScanResult_TotalArchivedFiles_SumsAcrossArchives()
|
||||
{
|
||||
var entries1 = new[]
|
||||
{
|
||||
new PhpPharEntry("file1.php", 100, 80, 0, 0, PhpPharCompression.None, null),
|
||||
new PhpPharEntry("file2.php", 200, 150, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
var entries2 = new[]
|
||||
{
|
||||
new PhpPharEntry("file3.php", 300, 250, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive1 = new PhpPharArchive("/test1.phar", "test1.phar", null, null, entries1, null);
|
||||
var archive2 = new PhpPharArchive("/test2.phar", "test2.phar", null, null, entries2, null);
|
||||
var result = new PhpPharScanResult(new[] { archive1, archive2 }, Array.Empty<PhpPharUsage>());
|
||||
|
||||
Assert.Equal(3, result.TotalArchivedFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharScanResult_ArchivesWithVendor_FiltersCorrectly()
|
||||
{
|
||||
var entriesWithVendor = new[]
|
||||
{
|
||||
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
var entriesWithoutVendor = new[]
|
||||
{
|
||||
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
|
||||
var archive1 = new PhpPharArchive("/with-vendor.phar", "with-vendor.phar", null, null, entriesWithVendor, null);
|
||||
var archive2 = new PhpPharArchive("/without-vendor.phar", "without-vendor.phar", null, null, entriesWithoutVendor, null);
|
||||
var result = new PhpPharScanResult(new[] { archive1, archive2 }, Array.Empty<PhpPharUsage>());
|
||||
|
||||
var archivesWithVendor = result.ArchivesWithVendor.ToList();
|
||||
Assert.Single(archivesWithVendor);
|
||||
Assert.Equal("with-vendor.phar", archivesWithVendor[0].RelativePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharScanResult_CreateMetadata_IncludesAllCounts()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null)
|
||||
};
|
||||
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
|
||||
var usage = new PhpPharUsage("src/Main.php", 10, "phar://test.phar/file.php", "test.phar/file.php");
|
||||
var result = new PhpPharScanResult(new[] { archive }, new[] { usage });
|
||||
|
||||
var metadata = result.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("1", metadata["phar.archive_count"]);
|
||||
Assert.Equal("1", metadata["phar.usage_count"]);
|
||||
Assert.Equal("1", metadata["phar.total_archived_files"]);
|
||||
Assert.Equal("1", metadata["phar.archives_with_vendor"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PhpPharUsage Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpPharUsage_RecordProperties_SetCorrectly()
|
||||
{
|
||||
var usage = new PhpPharUsage("src/Main.php", 42, "include 'phar://app.phar/Helper.php';", "app.phar/Helper.php");
|
||||
|
||||
Assert.Equal("src/Main.php", usage.SourceFile);
|
||||
Assert.Equal(42, usage.SourceLine);
|
||||
Assert.Equal("include 'phar://app.phar/Helper.php';", usage.Snippet);
|
||||
Assert.Equal("app.phar/Helper.php", usage.PharPath);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Compression and Signature Enums Tests
|
||||
|
||||
[Fact]
|
||||
public void PhpPharCompression_HasExpectedValues()
|
||||
{
|
||||
Assert.Equal(0, (int)PhpPharCompression.None);
|
||||
Assert.Equal(1, (int)PhpPharCompression.GZip);
|
||||
Assert.Equal(2, (int)PhpPharCompression.BZip2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhpPharSignatureType_HasExpectedValues()
|
||||
{
|
||||
Assert.Equal(0, (int)PhpPharSignatureType.None);
|
||||
Assert.Equal(1, (int)PhpPharSignatureType.Md5);
|
||||
Assert.Equal(2, (int)PhpPharSignatureType.Sha1);
|
||||
Assert.Equal(3, (int)PhpPharSignatureType.Sha256);
|
||||
Assert.Equal(4, (int)PhpPharSignatureType.Sha512);
|
||||
Assert.Equal(5, (int)PhpPharSignatureType.OpenSsl);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static byte[] CreateMinimalPharBytes(string stub)
|
||||
{
|
||||
// Create a minimal PHAR file structure
|
||||
// This is a simplified version - real PHARs have more complex structure
|
||||
var stubBytes = Encoding.UTF8.GetBytes(stub);
|
||||
|
||||
// Add some padding and a minimal manifest structure after __HALT_COMPILER();
|
||||
var padding = new byte[] { 0x0D, 0x0A }; // CRLF
|
||||
|
||||
// Minimal manifest: 4 bytes length + 4 bytes file count + 2 bytes API + 4 bytes flags + 4 bytes alias len + 4 bytes metadata len
|
||||
var manifestLength = 18u;
|
||||
var fileCount = 0u;
|
||||
var apiVersion = (ushort)0x1100;
|
||||
var flags = 0u;
|
||||
var aliasLength = 0u;
|
||||
var metadataLength = 0u;
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write(stubBytes, 0, stubBytes.Length);
|
||||
ms.Write(padding, 0, padding.Length);
|
||||
ms.Write(BitConverter.GetBytes(manifestLength), 0, 4);
|
||||
ms.Write(BitConverter.GetBytes(fileCount), 0, 4);
|
||||
ms.Write(BitConverter.GetBytes(apiVersion), 0, 2);
|
||||
ms.Write(BitConverter.GetBytes(flags), 0, 4);
|
||||
ms.Write(BitConverter.GetBytes(aliasLength), 0, 4);
|
||||
ms.Write(BitConverter.GetBytes(metadataLength), 0, 4);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -30,7 +30,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Lang.Tests/StellaOps.Scanner.Analyzers.Lang.Tests.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/StellaOps.Scanner.Analyzers.Lang.Php.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
|
||||
|
||||
@@ -5,8 +5,14 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**/*.cs" Exclude="obj/**;bin/**" />
|
||||
<None Include="**/*" Exclude="**/*.cs;bin/**;obj/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
|
||||
Reference in New Issue
Block a user