From c2c6b58b416bc36020cc9d593f44c75e7401fbb7 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 11 Nov 2025 15:30:22 +0200 Subject: [PATCH] feat: Add Promotion-Time Attestations for Stella Ops - Introduced a new document for promotion-time attestations, detailing the purpose, predicate schema, producer workflow, verification flow, APIs, and security considerations. - Implemented the `stella.ops/promotion@v1` predicate schema to capture promotion evidence including image digest, SBOM/VEX artifacts, and Rekor proof. - Defined producer responsibilities and workflows for CLI orchestration, signer responsibilities, and Export Center integration. - Added verification steps for auditors to validate promotion attestations offline. feat: Create Symbol Manifest v1 Specification - Developed a specification for Symbol Manifest v1 to provide a deterministic format for publishing debug symbols and source maps. - Defined the manifest structure, including schema, entries, source maps, toolchain, and provenance. - Outlined upload and verification processes, resolve APIs, runtime proxy, caching, and offline bundle generation. - Included security considerations and related tasks for implementation. chore: Add Ruby Analyzer with Git Sources - Created a Gemfile and Gemfile.lock for Ruby analyzer with dependencies on git-gem, httparty, and path-gem. - Implemented main application logic to utilize the defined gems and output their versions. - Added expected JSON output for the Ruby analyzer to validate the integration of the new gems and their functionalities. - Developed internal observation classes for Ruby packages, runtime edges, and capabilities, including serialization logic for observations. test: Add tests for Ruby Analyzer - Created test fixtures for Ruby analyzer, including Gemfile, Gemfile.lock, main application, and expected JSON output. - Ensured that the tests validate the correct integration and functionality of the Ruby analyzer with the specified gems. --- docs/examples/policies/baseline.md | 33 +- docs/examples/policies/baseline.stella | 26 +- docs/implplan/SPRINT_126_policy_reasoning.md | 1 + .../SPRINT_138_scanner_ruby_parity.md | 9 +- docs/implplan/SPRINT_163_exportcenter_ii.md | 1 + ...RINT_186_record_deterministic_execution.md | 11 +- docs/implplan/SPRINT_202_cli_ii.md | 4 +- docs/implplan/SPRINT_203_cli_iii.md | 2 + docs/implplan/SPRINT_209_ui_i.md | 2 + docs/implplan/SPRINT_304_docs_tasks_md_iv.md | 6 +- .../SPRINT_401_reachability_evidence_chain.md | 12 +- docs/implplan/SPRINT_505_ops_devops_iii.md | 2 + docs/implplan/SPRINT_513_provenance.md | 3 +- docs/modules/policy/README.md | 1 + docs/modules/policy/TASKS.md | 5 + .../design/ruby-capability-predicates.md | 82 ++++ docs/modules/scanner/design/ruby-analyzer.md | 25 +- docs/modules/scanner/determinism-score.md | 87 ++++ docs/modules/scanner/entropy.md | 126 +++++ docs/policy/dsl.md | 32 +- docs/release/promotion-attestations.md | 111 +++++ docs/specs/SYMBOL_MANIFEST_v1.md | 121 +++++ .../build_offline_kit.cpython-312.pyc | Bin 20807 -> 25407 bytes .../mirror_debug_store.cpython-312.pyc | Bin 10973 -> 0 bytes .../test_build_offline_kit.cpython-312.pyc | Bin 11107 -> 0 bytes ops/offline-kit/build_offline_kit.py | 50 +- seed-data/analyzers/ruby/git-sources/Gemfile | 12 + .../analyzers/ruby/git-sources/Gemfile.lock | 31 ++ .../analyzers/ruby/git-sources/app/main.rb | 7 + .../analyzers/ruby/git-sources/expected.json | 130 +++++ .../vendor/cache/path-gem-2.1.3.gem | 0 .../git-sources/vendor/plugins/path-gem/.keep | 1 + .../StellaOps.Cli/Commands/CommandHandlers.cs | 1 + .../Compilation/PolicyParser.cs | 38 +- .../Evaluation/PolicyEvaluationContext.cs | 40 +- .../Evaluation/PolicyExpressionEvaluator.cs | 445 +++++++++++++++--- .../PolicyEvaluatorTests.cs | 185 ++++++-- .../Observations/RubyObservationBuilder.cs | 83 ++++ .../Observations/RubyObservationDocument.cs | 31 ++ .../Observations/RubyObservationSerializer.cs | 114 +++++ .../Internal/RubyBundlerConfig.cs | 12 +- .../Internal/RubyLockParser.cs | 17 +- .../Internal/RubyPackageCollector.cs | 2 +- .../Internal/RubyVendorArtifactCollector.cs | 31 +- .../RubyLanguageAnalyzer.cs | 119 ++++- ...ellaOps.Scanner.Analyzers.Lang.Ruby.csproj | 1 + .../TASKS.md | 2 +- .../Contracts/ScanAnalysisKeys.cs | 2 + .../Fixtures/lang/ruby/basic/expected.json | 34 +- .../Fixtures/lang/ruby/git-sources/Gemfile | 12 + .../lang/ruby/git-sources/Gemfile.lock | 31 ++ .../lang/ruby/git-sources/app/main.rb | 7 + .../lang/ruby/git-sources/expected.json | 154 ++++++ .../vendor/cache/path-gem-2.1.3.gem | 0 .../lang/ruby/workspace/expected.json | 194 +++++++- .../Lang/Ruby/RubyLanguageAnalyzerTests.cs | 15 + 56 files changed, 2305 insertions(+), 198 deletions(-) create mode 100644 docs/modules/policy/TASKS.md create mode 100644 docs/modules/policy/design/ruby-capability-predicates.md create mode 100644 docs/modules/scanner/determinism-score.md create mode 100644 docs/modules/scanner/entropy.md create mode 100644 docs/release/promotion-attestations.md create mode 100644 docs/specs/SYMBOL_MANIFEST_v1.md delete mode 100644 ops/offline-kit/__pycache__/mirror_debug_store.cpython-312.pyc delete mode 100644 ops/offline-kit/__pycache__/test_build_offline_kit.cpython-312.pyc create mode 100644 seed-data/analyzers/ruby/git-sources/Gemfile create mode 100644 seed-data/analyzers/ruby/git-sources/Gemfile.lock create mode 100644 seed-data/analyzers/ruby/git-sources/app/main.rb create mode 100644 seed-data/analyzers/ruby/git-sources/expected.json create mode 100644 seed-data/analyzers/ruby/git-sources/vendor/cache/path-gem-2.1.3.gem create mode 100644 seed-data/analyzers/ruby/git-sources/vendor/plugins/path-gem/.keep create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile.lock create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/app/main.rb create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/expected.json create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/vendor/cache/path-gem-2.1.3.gem diff --git a/docs/examples/policies/baseline.md b/docs/examples/policies/baseline.md index 0c0106a35..6343ea380 100644 --- a/docs/examples/policies/baseline.md +++ b/docs/examples/policies/baseline.md @@ -42,20 +42,33 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" { because "Respect strong vendor VEX claims." } - rule alert_warn_eol_runtime priority 1 { - when severity.normalized <= "Medium" - and sbom.has_tag("runtime:eol") - then warn message "Runtime marked as EOL; upgrade recommended." - because "Deprecated runtime should be upgraded." - } -} -``` + rule alert_warn_eol_runtime priority 1 { + when severity.normalized <= "Medium" + and sbom.has_tag("runtime:eol") + then warn message "Runtime marked as EOL; upgrade recommended." + because "Deprecated runtime should be upgraded." + } + + rule block_ruby_dev priority 4 { + when sbom.any_component(ruby.group("development") and ruby.declared_only()) + then status := "blocked" + because "Development-only Ruby gems without install evidence cannot ship." + } + + rule warn_ruby_git_sources { + when sbom.any_component(ruby.source("git")) + then warn message "Git-sourced Ruby gem present; review required." + because "Git-sourced Ruby dependencies require explicit review." + } +} +``` ## Commentary - **Severity profile** tightens vendor weights and applies exposure modifiers so internet-facing/high severity pairs escalate automatically. - **VEX rule** only honours strong justifications, preventing weaker claims from hiding issues. -- **Warnings first** – The `alert_warn_eol_runtime` rule name ensures it sorts before the require-VEX rule, keeping alerts visible without flipping to `RequiresVex`. +- **Warnings first** – The `alert_warn_eol_runtime` rule name ensures it sorts before the require-VEX rule, keeping alerts visible without flipping to `RequiresVex`. +- **Ruby supply-chain guardrails** enforce Bundler groups and provenance: development-only gems without install evidence are blocked and git-sourced gems trigger review warnings. - Works well as shared `tenant-global` baseline; use tenant overrides for stricter tolerant environments. ## Try it out @@ -76,4 +89,4 @@ stella policy simulate P-baseline --candidate 1 --sbom sbom:sample-prod --- -*Last updated: 2025-10-26.* +*Last updated: 2025-11-10.* diff --git a/docs/examples/policies/baseline.stella b/docs/examples/policies/baseline.stella index c1a04e21b..c967cd7b6 100644 --- a/docs/examples/policies/baseline.stella +++ b/docs/examples/policies/baseline.stella @@ -37,10 +37,22 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" { because "Respect strong vendor VEX claims." } - rule alert_warn_eol_runtime priority 1 { - when severity.normalized <= "Medium" - and sbom.has_tag("runtime:eol") - then warn message "Runtime marked as EOL; upgrade recommended." - because "Deprecated runtime should be upgraded." - } -} + rule alert_warn_eol_runtime priority 1 { + when severity.normalized <= "Medium" + and sbom.has_tag("runtime:eol") + then warn message "Runtime marked as EOL; upgrade recommended." + because "Deprecated runtime should be upgraded." + } + + rule block_ruby_dev priority 4 { + when sbom.any_component(ruby.group("development") and ruby.declared_only()) + then status := "blocked" + because "Development-only Ruby gems without install evidence cannot ship." + } + + rule warn_ruby_git_sources { + when sbom.any_component(ruby.source("git")) + then warn message "Git-sourced Ruby gem present; review required." + because "Git-sourced Ruby dependencies require explicit review." + } +} diff --git a/docs/implplan/SPRINT_126_policy_reasoning.md b/docs/implplan/SPRINT_126_policy_reasoning.md index c6e5ccdd3..42430c645 100644 --- a/docs/implplan/SPRINT_126_policy_reasoning.md +++ b/docs/implplan/SPRINT_126_policy_reasoning.md @@ -25,3 +25,4 @@ Focus: Policy & Reasoning focus on Policy (phase IV). | 13 | POLICY-ENGINE-70-004 | TODO | Extend metrics/tracing/logging for exception application (latency, counts, expiring events) and include AOC references in logs (Deps: POLICY-ENGINE-70-003) | Policy Guild, Observability Guild / src/Policy/StellaOps.Policy.Engine | | 14 | POLICY-ENGINE-70-005 | TODO | Provide APIs/workers hook for exception activation/expiry (auto start/end) and event emission (`exception.activated/expired`) (Deps: POLICY-ENGINE-70-004) | Policy Guild, Scheduler Worker Guild / src/Policy/StellaOps.Policy.Engine | | 15 | POLICY-ENGINE-80-001 | TODO | Integrate reachability/exploitability inputs into evaluation pipeline (state/score/confidence) with caching and explain support (Deps: POLICY-ENGINE-70-005) | Policy Guild, Signals Guild / src/Policy/StellaOps.Policy.Engine | +| 16 | POLICY-RISK-90-001 | TODO | Ingest entropy penalty inputs from Scanner (`entropy.report.json`, `layer_summary.json`), extend trust algebra with configurable weights/caps, and expose explanations/metrics for opaque ratio penalties (`docs/modules/scanner/entropy.md`). | Policy Guild, Scanner Guild / src/Policy/StellaOps.Policy.Engine | diff --git a/docs/implplan/SPRINT_138_scanner_ruby_parity.md b/docs/implplan/SPRINT_138_scanner_ruby_parity.md index b7efbeeb7..a4267ec1b 100644 --- a/docs/implplan/SPRINT_138_scanner_ruby_parity.md +++ b/docs/implplan/SPRINT_138_scanner_ruby_parity.md @@ -14,12 +14,12 @@ | `SCANNER-ENG-0013` | TODO | Plan Swift Package Manager coverage (Package.resolved, xcframeworks, runtime hints) with policy hooks. | Swift Analyzer Guild (src/Scanner/StellaOps.Scanner.Analyzers.Lang.Swift) | — | | `SCANNER-ENG-0014` | TODO | Align Kubernetes/VM target coverage between Scanner and Zastava per comparison findings; publish joint roadmap. | Runtime Guild, Zastava Guild (docs/modules/scanner) | — | | `SCANNER-ENG-0015` | DOING (2025-11-09) | Document DSSE/Rekor operator enablement guidance and rollout levers surfaced in the gap analysis. | Export Center Guild, Scanner Guild (docs/modules/scanner) | — | -| `SCANNER-ENG-0016` | DOING (2025-11-10) | Implement `RubyLockCollector` + vendor cache ingestion per design §4.1–4.3. | Ruby Analyzer Guild (src/Scanner/StellaOps.Scanner.Analyzers.Lang.Ruby) | SCANNER-ENG-0009 | +| `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector and vendor ingestion finalized: Bundler config overrides honoured, workspace lockfiles merged, vendor bundles normalised, and deterministic fixtures added. | Ruby Analyzer Guild (src/Scanner/StellaOps.Scanner.Analyzers.Lang.Ruby) | SCANNER-ENG-0009 | | `SCANNER-ENG-0017` | DONE (2025-11-09) | Build the runtime require/autoload graph builder with tree-sitter Ruby per design §4.4 and integrate EntryTrace hints. | Ruby Analyzer Guild (src/Scanner/StellaOps.Scanner.Analyzers.Lang.Ruby) | SCANNER-ENG-0016 | | `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals as defined in design §4.5 with policy predicate hooks. | Ruby Analyzer Guild (src/Scanner/StellaOps.Scanner.Analyzers.Lang.Ruby) | SCANNER-ENG-0017 | | `SCANNER-ENG-0019` | DOING (2025-11-10) | Ship Ruby CLI verbs (`stella ruby inspect|resolve`) and Offline Kit packaging per design §4.6. | Ruby Analyzer Guild, CLI Guild (src/Scanner/StellaOps.Scanner.Analyzers.Lang.Ruby) | SCANNER-ENG-0016..0018 | -| `SCANNER-LIC-0001` | DOING (2025-11-02) | Vet tree-sitter Ruby licensing + Offline Kit packaging requirements and document SPDX posture. | Scanner Guild, Legal Guild (docs/modules/scanner) | SCANNER-ENG-0016 | -| `SCANNER-POLICY-0001` | TODO | Define Policy Engine predicates for Ruby groups/capabilities and align lattice weights. | Policy Guild, Ruby Analyzer Guild (docs/modules/scanner) | SCANNER-ENG-0018 | +| `SCANNER-LIC-0001` | DONE (2025-11-10) | Tree-sitter licensing captured, `NOTICE.md` updated, and Offline Kit now mirrors `third-party-licenses/` with ruby artifacts. | Scanner Guild, Legal Guild (docs/modules/scanner) | SCANNER-ENG-0016 | +| `SCANNER-POLICY-0001` | DONE (2025-11-10) | Ruby predicates shipped: Policy Engine exposes `sbom.any_component` + `ruby.*`, tests updated, DSL/offline-kit docs refreshed. | Policy Guild, Ruby Analyzer Guild (docs/modules/scanner) | SCANNER-ENG-0018 | | `SCANNER-CLI-0001` | DONE (2025-11-10) | Coordinate CLI UX/help text for new Ruby verbs and update CLI docs/golden outputs. | CLI Guild, Ruby Analyzer Guild (src/Cli/StellaOps.Cli) | SCANNER-ENG-0019 | ### Updates — 2025-11-09 @@ -27,4 +27,5 @@ - `SCANNER-CLI-0001`: Completed Spectre table wrapping fix for runtime/lockfile columns, expanded Ruby resolve JSON assertions, removed ad-hoc debug artifacts, and drafted CLI docs covering `stellaops-cli ruby inspect|resolve`. Pending: final verification + handoff once docs/tests merge. - `SCANNER-CLI-0001`: Wired `stellaops-cli ruby inspect|resolve` into `CommandFactory` so the verbs are available via `System.CommandLine` with the expected `--root`, `--image/--scan-id`, and `--format` options; `dotnet test ... --filter Ruby` passes. - `SCANNER-CLI-0001`: Added CLI unit tests (`CommandFactoryTests`, Ruby inspect JSON assertions) to guard the new verbs and runtime metadata output; `dotnet test src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj --filter "CommandFactoryTests|Ruby"` now covers the CLI surface. -- `SCANNER-ENG-0016`: 2025-11-10 — resumed to finish `RubyLockCollector` + vendor cache ingestion (Codex agent) per §4.1–4.3, targeting lockfile multi-source coverage and bundler group metadata. +- `SCANNER-ENG-0016`: 2025-11-10 — Completed Ruby lock collector and vendor ingestion work: honour `.bundle/config` overrides, fold workspace lockfiles, emit bundler groups, add Ruby analyzer fixtures/goldens (including new git/path offline kit mirror), and `dotnet test ... --filter Ruby` passes. +- `SCANNER-ENG-0009`: Emitted observation payload + `ruby-observation` component summarising packages, runtime edges, and capability flags for Policy/Surface exports; fixtures updated for determinism and Offline Kit now ships the observation JSON. diff --git a/docs/implplan/SPRINT_163_exportcenter_ii.md b/docs/implplan/SPRINT_163_exportcenter_ii.md index 08c493703..edd43099e 100644 --- a/docs/implplan/SPRINT_163_exportcenter_ii.md +++ b/docs/implplan/SPRINT_163_exportcenter_ii.md @@ -13,6 +13,7 @@ EXPORT-OBS-51-001 | TODO | Emit metrics for export planner latency, bundle build EXPORT-OBS-52-001 | TODO | Publish timeline events for export lifecycle (`export.requested`, `export.built`, `export.distributed`, `export.failed`) embedding manifest hashes and evidence refs. Provide dedupe + retry logic. Dependencies: EXPORT-OBS-51-001. | Exporter Service Guild (src/ExportCenter/StellaOps.ExportCenter) EXPORT-OBS-53-001 | TODO | Push export manifests + distribution transcripts to evidence locker bundles, ensuring Merkle root alignment and DSSE pre-sign data available. Dependencies: EXPORT-OBS-52-001. | Exporter Service Guild, Evidence Locker Guild (src/ExportCenter/StellaOps.ExportCenter) EXPORT-OBS-54-001 | TODO | Produce DSSE attestations for each export artifact and distribution target, expose verification API `/exports/{id}/attestation`, and integrate with CLI verify path. Dependencies: EXPORT-OBS-53-001. | Exporter Service Guild, Provenance Guild (src/ExportCenter/StellaOps.ExportCenter) +EXPORT-OBS-54-002 | TODO | Add promotion attestation assembly to export runs (compute SBOM/VEX digests, embed Rekor proofs, bundle DSSE envelopes) and ensure Offline Kit packaging includes the resulting JSON + DSSE envelopes. Dependencies: EXPORT-OBS-54-001, PROV-OBS-53-003. | Exporter Service Guild, Provenance Guild (src/ExportCenter/StellaOps.ExportCenter) EXPORT-OBS-55-001 | TODO | Add incident mode enhancements (extra tracing for slow exports, additional debug logs, retention bump). Emit incident activation events to timeline + notifier. Dependencies: EXPORT-OBS-54-001. | Exporter Service Guild, DevOps Guild (src/ExportCenter/StellaOps.ExportCenter) EXPORT-RISK-69-001 | TODO | Add Export Center job handler `risk-bundle` with provider selection, manifest signing, and audit logging. | Exporter Service Guild, Risk Bundle Export Guild (src/ExportCenter/StellaOps.ExportCenter) EXPORT-RISK-69-002 | TODO | Enable simulation report exports pulling scored data + explainability snapshots. Dependencies: EXPORT-RISK-69-001. | Exporter Service Guild, Risk Engine Guild (src/ExportCenter/StellaOps.ExportCenter) diff --git a/docs/implplan/SPRINT_186_record_deterministic_execution.md b/docs/implplan/SPRINT_186_record_deterministic_execution.md index 57c013ddc..45ce37f7f 100644 --- a/docs/implplan/SPRINT_186_record_deterministic_execution.md +++ b/docs/implplan/SPRINT_186_record_deterministic_execution.md @@ -9,6 +9,15 @@ Task ID | State | Task description | Owners (Source) SCAN-REPLAY-186-001 | TODO | Implement `record` mode in `StellaOps.Scanner.WebService` (manifest assembly, policy/feed/tool hash capture, CAS uploads) and document the workflow in `docs/modules/scanner/architecture.md` with references to `docs/replay/DETERMINISTIC_REPLAY.md` Section 6. | Scanner Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/architecture.md`) SCAN-REPLAY-186-002 | TODO | Update `StellaOps.Scanner.Worker` analyzers to consume sealed input bundles, enforce deterministic ordering, and contribute Merkle metadata; extend `docs/modules/scanner/deterministic-execution.md` (new) summarising invariants drawn from `docs/replay/DETERMINISTIC_REPLAY.md` Section 4. | Scanner Guild (`src/Scanner/StellaOps.Scanner.Worker`, `docs/modules/scanner/deterministic-execution.md`) SIGN-REPLAY-186-003 | TODO | Extend Signer/Authority DSSE flows to cover replay manifest/bundle payload types with multi-profile support; refresh `docs/modules/signer/architecture.md` and `docs/modules/authority/architecture.md` to capture the new signing/verification path referencing `docs/replay/DETERMINISTIC_REPLAY.md` Section 5. | Signing Guild (`src/Signer/StellaOps.Signer`, `src/Authority/StellaOps.Authority`) +SIGN-CORE-186-004 | TODO | Replace the HMAC demo implementation in `StellaOps.Signer` with StellaOps.Cryptography providers (keyless + KMS), including provider selection, key material loading, and cosign-compatible DSSE signature output. | Signing Guild (`src/Signer/StellaOps.Signer`, `src/__Libraries/StellaOps.Cryptography`) +SIGN-CORE-186-005 | TODO | Refactor `SignerStatementBuilder` to support StellaOps predicate types (e.g., `stella.ops/promotion@v1`) and delegate payload canonicalisation to the Provenance library once available. | Signing Guild (`src/Signer/StellaOps.Signer.Core`) +SIGN-TEST-186-006 | TODO | Upgrade signer integration tests to run against the real crypto abstraction and fixture predicates (promotion, SBOM, replay), replacing stub tokens/digests with deterministic test data. | Signing Guild, QA Guild (`src/Signer/StellaOps.Signer.Tests`) +AUTH-VERIFY-186-007 | TODO | Expose an Authority-side verification helper/service that validates DSSE signatures and Rekor proofs for promotion attestations using trusted checkpoints, enabling offline audit flows. | Authority Guild, Provenance Guild (`src/Authority/StellaOps.Authority`, `src/Provenance/StellaOps.Provenance.Attestation`) +SCAN-DETER-186-008 | TODO | Add deterministic execution switches to Scanner (fixed clock, RNG seed, concurrency cap, feed/policy snapshot pins, log filtering) available via CLI/env/config so repeated runs stay hermetic. | Scanner Guild (`src/Scanner/StellaOps.Scanner.WebService`, `src/Scanner/StellaOps.Scanner.Worker`) +SCAN-DETER-186-009 | TODO | Build a determinism harness that replays N scans per image, canonicalises SBOM/VEX/findings/log outputs, and records per-run hash matrices (see `docs/modules/scanner/determinism-score.md`). | Scanner Guild, QA Guild (`src/Scanner/StellaOps.Scanner.Replay`, `src/Scanner/__Tests`) +SCAN-DETER-186-010 | TODO | Emit and publish `determinism.json` (scores, artifact hashes, non-identical diffs) alongside each scanner release via CAS/object storage APIs (documented in `docs/modules/scanner/determinism-score.md`). | Scanner Guild, Export Center Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/operations/release.md`) +SCAN-ENTROPY-186-011 | TODO | Implement entropy analysis for ELF/PE/Mach-O executables and large opaque blobs (sliding-window metrics, section heuristics), flagging high-entropy regions and recording offsets/hints (see `docs/modules/scanner/entropy.md`). | Scanner Guild (`src/Scanner/StellaOps.Scanner.Worker`, `src/Scanner/__Libraries`) +SCAN-ENTROPY-186-012 | TODO | Generate `entropy.report.json` and image-level penalties, attach evidence to scan manifests/attestations, and expose opaque ratios for downstream policy engines (`docs/modules/scanner/entropy.md`). | Scanner Guild, Provenance Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/replay/DETERMINISTIC_REPLAY.md`) DOCS-REPLAY-186-004 | TODO | Author `docs/replay/TEST_STRATEGY.md` (golden replay, feed drift, tool upgrade) and link it from both replay docs and Scanner architecture pages. | Docs Guild (`docs`) -> 2025-11-03: `docs/replay/TEST_STRATEGY.md` drafted — Scanner/Signer guilds should shift replay tasks to **DOING** when engineering picks up implementation. \ No newline at end of file +> 2025-11-03: `docs/replay/TEST_STRATEGY.md` drafted — Scanner/Signer guilds should shift replay tasks to **DOING** when engineering picks up implementation. diff --git a/docs/implplan/SPRINT_202_cli_ii.md b/docs/implplan/SPRINT_202_cli_ii.md index 862449df3..764730fe2 100644 --- a/docs/implplan/SPRINT_202_cli_ii.md +++ b/docs/implplan/SPRINT_202_cli_ii.md @@ -16,9 +16,11 @@ CLI-EXPORT-37-001 | TODO | Provide scheduling (`stella export schedule`), retent CLI-FORENSICS-53-001 | TODO | Implement `stella forensic snapshot create --case` and `snapshot list/show` commands invoking evidence locker APIs, surfacing manifest digests, and storing local cache metadata. | DevEx/CLI Guild, Evidence Locker Guild (src/Cli/StellaOps.Cli) CLI-FORENSICS-54-001 | TODO | Provide `stella forensic verify ` command validating checksums, DSSE signatures, and timeline chain-of-custody. Support JSON/pretty output and exit codes for CI. Dependencies: CLI-FORENSICS-53-001. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli) CLI-FORENSICS-54-002 | TODO | Implement `stella forensic attest show ` listing attestation details (signer, timestamp, subjects) and verifying signatures. Dependencies: CLI-FORENSICS-54-001. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli) +CLI-PROMO-70-001 | TODO | Add `stella promotion assemble` command that resolves image digests, hashes SBOM/VEX artifacts, fetches Rekor proofs from Attestor, and emits the `stella.ops/promotion@v1` JSON payload (see `docs/release/promotion-attestations.md`). | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli) +CLI-DETER-70-003 | TODO | Provide `stella detscore run` that executes the determinism harness locally (fixed clock, seeded RNG, canonical hashes) and writes `determinism.json`, supporting CI/non-zero threshold exit codes (`docs/modules/scanner/determinism-score.md`). | DevEx/CLI Guild, Scanner Guild (src/Cli/StellaOps.Cli) CLI-LNM-22-001 | TODO | Implement `stella advisory obs get/linkset show/export` commands with JSON/OSV output, pagination, and conflict display; ensure `ERR_AGG_*` mapping. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-LNM-22-002 | TODO | Implement `stella vex obs get/linkset show` commands with product filters, status filters, and JSON output for CI usage. Dependencies: CLI-LNM-22-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-NOTIFY-38-001 | BLOCKED (2025-10-29) | Implement `stella notify rules | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-NOTIFY-39-001 | BLOCKED (2025-10-29) | Add simulation (`stella notify simulate`) and digest commands with diff output and schedule triggering, including dry-run mode. Dependencies: CLI-NOTIFY-38-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-NOTIFY-40-001 | TODO | Provide ack token redemption workflow, escalation management, localization previews, and channel health checks. Dependencies: CLI-NOTIFY-39-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) -CLI-OBS-50-001 | TODO | Ensure CLI HTTP client propagates `traceparent` headers for all commands, prints correlation IDs on failure, and records trace IDs in verbose logs (scrubbed). | DevEx/CLI Guild (src/Cli/StellaOps.Cli) \ No newline at end of file +CLI-OBS-50-001 | TODO | Ensure CLI HTTP client propagates `traceparent` headers for all commands, prints correlation IDs on failure, and records trace IDs in verbose logs (scrubbed). | DevEx/CLI Guild (src/Cli/StellaOps.Cli) diff --git a/docs/implplan/SPRINT_203_cli_iii.md b/docs/implplan/SPRINT_203_cli_iii.md index d867b29b5..db09e2609 100644 --- a/docs/implplan/SPRINT_203_cli_iii.md +++ b/docs/implplan/SPRINT_203_cli_iii.md @@ -14,6 +14,8 @@ CLI-ORCH-32-001 | TODO | Implement `stella orch sources | DevEx/CLI Guild (src/C CLI-ORCH-33-001 | TODO | Add action verbs (`sources test. Dependencies: CLI-ORCH-32-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-ORCH-34-001 | TODO | Provide backfill wizard (`--from/--to --dry-run`), quota management (`quotas get. Dependencies: CLI-ORCH-33-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-PACKS-42-001 | TODO | Implement Task Pack commands (`pack plan/run/push/pull/verify`) with schema validation, expression sandbox, plan/simulate engine, remote execution. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) +CLI-PROMO-70-002 | TODO | Implement `stella promotion attest` / `promotion verify` commands that sign the promotion payload via Signer, retrieve DSSE bundles from Attestor, and perform offline verification against trusted checkpoints (`docs/release/promotion-attestations.md`). Dependencies: CLI-PROMO-70-001. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli) +CLI-DETER-70-004 | TODO | Add `stella detscore report` to summarise published `determinism.json` files (overall score, per-image matrix) and integrate with release notes/air-gap kits (`docs/modules/scanner/determinism-score.md`). Dependencies: CLI-DETER-70-003. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-PACKS-43-001 | TODO | Deliver advanced pack features (approvals pause/resume, secret injection, localization, man pages, offline cache). Dependencies: CLI-PACKS-42-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-PARITY-41-001 | TODO | Deliver parity command groups (`policy`, `sbom`, `vuln`, `vex`, `advisory`, `export`, `orchestrator`) with `--explain`, deterministic outputs, and parity matrix entries. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) CLI-PARITY-41-002 | TODO | Implement `notify`, `aoc`, `auth` command groups, idempotency keys, shell completions, config docs, and parity matrix export tooling. Dependencies: CLI-PARITY-41-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli) diff --git a/docs/implplan/SPRINT_209_ui_i.md b/docs/implplan/SPRINT_209_ui_i.md index 09d8242aa..0592fa948 100644 --- a/docs/implplan/SPRINT_209_ui_i.md +++ b/docs/implplan/SPRINT_209_ui_i.md @@ -24,3 +24,5 @@ UI-GRAPH-24-006 | TODO | Ensure accessibility (keyboard nav, screen reader label UI-LNM-22-001 | TODO | Build Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links. Docs `DOCS-LNM-22-005` waiting on delivered UI for screenshots + flows. | UI Guild, Policy Guild (src/UI/StellaOps.UI) UI-SBOM-DET-01 | TODO | Add a “Determinism” badge plus drill-down that surfaces fragment hashes, `_composition.json`, and Merkle root consistency when viewing scan details (per `docs/modules/scanner/deterministic-sbom-compose.md`). | UI Guild (src/UI/StellaOps.UI) | UI-POLICY-DET-01 | TODO | Wire policy gate indicators + remediation hints into Release/Policy flows, blocking publishes when determinism checks fail; coordinate with Policy Engine schema updates. Dependencies: UI-SBOM-DET-01. | UI Guild, Policy Guild (src/UI/StellaOps.UI) | +UI-ENTROPY-40-001 | TODO | Visualise entropy analysis per image (layer donut, file heatmaps, “Why risky?” chips) in Vulnerability Explorer and scan details, including opaque byte ratios and detector hints (see `docs/modules/scanner/entropy.md`). | UI Guild (src/UI/StellaOps.UI) | +UI-ENTROPY-40-002 | TODO | Add policy banners/tooltips explaining entropy penalties (block/warn thresholds, mitigation steps) and link to raw `entropy.report.json` evidence downloads (`docs/modules/scanner/entropy.md`). Dependencies: UI-ENTROPY-40-001. | UI Guild, Policy Guild (src/UI/StellaOps.UI) | diff --git a/docs/implplan/SPRINT_304_docs_tasks_md_iv.md b/docs/implplan/SPRINT_304_docs_tasks_md_iv.md index 52afa1d12..84b9e1cf8 100644 --- a/docs/implplan/SPRINT_304_docs_tasks_md_iv.md +++ b/docs/implplan/SPRINT_304_docs_tasks_md_iv.md @@ -21,4 +21,8 @@ DOCS-GRAPH-24-003 | TODO | Create `/docs/modules/graph/architecture-index.md` de DOCS-GRAPH-24-004 | TODO | Document `/docs/api/graph.md` and `/docs/api/vuln.md` avec endpoints, parameters, errors, RBAC. Dependencies: DOCS-GRAPH-24-003. | Docs Guild, BE-Base Platform Guild (docs) DOCS-GRAPH-24-005 | TODO | Update `/docs/modules/cli/guides/graph-and-vuln.md` covering new CLI commands, exit codes, scripting. Dependencies: DOCS-GRAPH-24-004. | Docs Guild, DevEx/CLI Guild (docs) DOCS-GRAPH-24-006 | TODO | Write `/docs/policy/ui-integration.md` explaining overlays, cache usage, simulator contracts. Dependencies: DOCS-GRAPH-24-005. | Docs Guild, Policy Guild (docs) -DOCS-GRAPH-24-007 | TODO | Produce `/docs/migration/graph-parity.md` with rollout plan, parity checks, fallback guidance. Dependencies: DOCS-GRAPH-24-006. | Docs Guild, DevOps Guild (docs) \ No newline at end of file +DOCS-GRAPH-24-007 | TODO | Produce `/docs/migration/graph-parity.md` with rollout plan, parity checks, fallback guidance. Dependencies: DOCS-GRAPH-24-006. | Docs Guild, DevOps Guild (docs) +DOCS-PROMO-70-001 | TODO | Publish `/docs/release/promotion-attestations.md` describing the promotion workflow (CLI commands, Signer/Attestor integration, offline verification) and update `/docs/forensics/provenance-attestation.md` with the new predicate. Dependencies: PROV-OBS-53-003, CLI-PROMO-70-002. | Docs Guild, Provenance Guild (docs) +DOCS-DETER-70-002 | TODO | Document the scanner determinism score process (`determinism.json` schema, CI harness, replay instructions) under `/docs/modules/scanner/determinism-score.md` and add a release-notes template entry. Dependencies: SCAN-DETER-186-010, DEVOPS-SCAN-90-004. | Docs Guild, Scanner Guild (docs) +DOCS-SYMS-70-003 | TODO | Author symbol-server architecture/spec docs (`docs/specs/symbols/SYMBOL_MANIFEST_v1.md`, API reference, bundle guide) and update reachability guides with symbol lookup workflow and tenant controls. Dependencies: SYMS-SERVER-401-011, SYMS-INGEST-401-013. | Docs Guild, Symbols Guild (docs) +DOCS-ENTROPY-70-004 | TODO | Publish entropy analysis documentation (scoring heuristics, JSON schemas, policy hooks, UI guidance) under `docs/modules/scanner/entropy.md` and update trust-lattice references. Dependencies: SCAN-ENTROPY-186-011/012, POLICY-RISK-90-001. | Docs Guild, Scanner Guild (docs) diff --git a/docs/implplan/SPRINT_401_reachability_evidence_chain.md b/docs/implplan/SPRINT_401_reachability_evidence_chain.md index 40dc5a703..f2f150889 100644 --- a/docs/implplan/SPRINT_401_reachability_evidence_chain.md +++ b/docs/implplan/SPRINT_401_reachability_evidence_chain.md @@ -13,17 +13,23 @@ _Theme:_ Finish the provable reachability pipeline (graph CAS → replay → DSS |---------|-------|------------------|-----------------| | GRAPH-CAS-401-001 | TODO | Finalize richgraph schema (`richgraph-v1`), emit canonical SymbolIDs, compute graph hash (BLAKE3), and store CAS manifests under `cas://reachability/graphs/{sha256}`. Update Scanner Worker adapters + fixtures. | Scanner Worker Guild (`src/Scanner/StellaOps.Scanner.Worker`) | | GAP-SYM-007 | TODO | Extend reachability evidence schema/DTOs with demangled symbol hints, `symbol.source`, confidence, and optional `code_block_hash`; ensure Scanner SBOM/evidence writers and CLI serializers emit the new fields deterministically. | Scanner Worker Guild & Docs Guild (`src/Scanner/StellaOps.Scanner.Models`, `docs/modules/scanner/architecture.md`, `docs/reachability/function-level-evidence.md`) | +| SCAN-REACH-401-009 | TODO | Ship .NET/JVM symbolizers and call-graph generators (roots, edges, framework adapters), merge results into component-level reachability manifests, and back them with golden fixtures. | Scanner Worker Guild (`src/Scanner/StellaOps.Scanner.Worker`, `src/Scanner/__Libraries`) | +| SYMS-SERVER-401-011 | TODO | Deliver `StellaOps.Symbols.Server` (REST+gRPC) with DSSE-verified uploads, Mongo/MinIO storage, tenant isolation, and deterministic debugId indexing; publish health/manifest APIs (spec: `docs/specs/SYMBOL_MANIFEST_v1.md`). | Symbols Guild (`src/Symbols/StellaOps.Symbols.Server`) | +| SYMS-CLIENT-401-012 | TODO | Ship `StellaOps.Symbols.Client` SDK (resolve/upload APIs, platform key derivation for ELF/PDB/Mach-O/JVM/Node, disk LRU cache) and integrate with Scanner.Symbolizer/runtime probes (ref. `docs/specs/SYMBOL_MANIFEST_v1.md`). | Symbols Guild (`src/Symbols/StellaOps.Symbols.Client`, `src/Scanner/StellaOps.Scanner.Symbolizer`) | +| SYMS-INGEST-401-013 | TODO | Build `symbols ingest` CLI to emit DSSE-signed `SymbolManifest v1`, upload blobs, and register Rekor entries; document GitLab/Gitea pipeline usage. | Symbols Guild, DevOps Guild (`src/Symbols/StellaOps.Symbols.Ingestor.Cli`, `docs/specs/SYMBOL_MANIFEST_v1.md`) | | SIGNALS-RUNTIME-401-002 | TODO | Ship `/signals/runtime-facts` ingestion for NDJSON (and gzip) batches, dedupe hits, and link runtime evidence CAS URIs to callgraph nodes. Include retention + RBAC tests. | Signals Guild (`src/Signals/StellaOps.Signals`) | -| SIGNALS-SCORING-401-003 | TODO | Extend `ReachabilityScoringService` to lattice states (`Unknown/NotPresent/Unreachable/Conditional/Reachable/Observed`), persist predicates + blocked edges, and expose `/graphs/{scanId}` CAS lookups. | Signals Guild (`src/Signals/StellaOps.Signals`) | +| RUNTIME-PROBE-401-010 | TODO | Implement lightweight runtime probes (EventPipe/.NET, JFR/JVM) that capture method enter events for the target components, package them as CAS traces, and feed them into the Signals ingestion pipeline. | Runtime Signals Guild (`src/Signals/StellaOps.Signals.Runtime`, `ops/probes`) | +| SIGNALS-SCORING-401-003 | TODO | Extend `ReachabilityScoringService` with deterministic scoring (static path +0.50, runtime hits +0.30/+0.10 sink, guard penalties, reflection penalty, floor 0.05), persist reachability labels (`reachable/conditional/unreachable`) and expose `/graphs/{scanId}` CAS lookups. | Signals Guild (`src/Signals/StellaOps.Signals`) | | REPLAY-401-004 | TODO | Bump replay manifest to v2 (feeds, analyzers, policies), have `ReachabilityReplayWriter` enforce CAS registration + hash sorting, and add deterministic tests to `tests/reachability/StellaOps.Reachability.FixtureTests`. | BE-Base Platform Guild (`src/__Libraries/StellaOps.Replay.Core`) | | AUTH-REACH-401-005 | TODO | Introduce DSSE predicate types for SBOM/Graph/VEX/Replay, plumb signing through Authority + Signer, and mirror statements to Rekor (including PQ variants where required). | Authority & Signer Guilds (`src/Authority/StellaOps.Authority`, `src/Signer/StellaOps.Signer`) | -| POLICY-VEX-401-006 | TODO | Policy Engine consumes reachability facts, emits OpenVEX with evidence references, updates SPL schema with `reachability.state/confidence` predicates, and produces API metrics. | Policy Guild (`src/Policy/StellaOps.Policy.Engine`, `src/Policy/__Libraries/StellaOps.Policy`) | +| POLICY-VEX-401-006 | TODO | Policy Engine consumes reachability facts, applies the deterministic score/label buckets (≥0.80 reachable, 0.30–0.79 conditional, <0.30 unreachable), emits OpenVEX with call-path proofs, and updates SPL schema with `reachability.state/confidence` predicates and suppression gates. | Policy Guild (`src/Policy/StellaOps.Policy.Engine`, `src/Policy/__Libraries/StellaOps.Policy`) | | UI-CLI-401-007 | TODO | Implement CLI `stella graph explain` + UI explain drawer showing signed call-path, predicates, runtime hits, and DSSE pointers; include counterfactual controls. | UI & CLI Guilds (`src/Cli/StellaOps.Cli`, `src/UI/StellaOps.UI`) | | QA-DOCS-401-008 | TODO | Wire `reachbench-2025-expanded` fixtures into CI, document CAS layouts + replay steps in `docs/reachability/DELIVERY_GUIDE.md`, and publish operator runbook for runtime ingestion. | QA & Docs Guilds (`docs`, `tests/README.md`) | | GAP-SIG-003 | TODO | Finish `/signals/runtime-facts` ingestion, add CAS-backed runtime storage, extend scoring to lattice states (`Unknown/NotPresent/Unreachable/Conditional/Reachable/Observed`), and emit `signals.fact.updated` events. Document retention/RBAC. | Signals Guild (`src/Signals/StellaOps.Signals`, `docs/reachability/function-level-evidence.md`) | | GAP-REP-004 | TODO | Enforce BLAKE3 hashing + CAS registration for graphs/traces before manifest writes, upgrade replay manifest v2 with analyzer versions/policy thresholds, and add deterministic tests. | BE-Base Platform Guild (`src/__Libraries/StellaOps.Replay.Core`, `docs/replay/DETERMINISTIC_REPLAY.md`) | -| GAP-POL-005 | TODO | Ingest reachability facts into Policy Engine, expose `reachability.state/confidence` in SPL/API, and generate OpenVEX evidence blocks referencing graph hashes + runtime facts with policy thresholds. | Policy Guild (`src/Policy/StellaOps.Policy.Engine`, `docs/modules/policy/architecture.md`, `docs/reachability/function-level-evidence.md`) | +| GAP-POL-005 | TODO | Ingest reachability facts into Policy Engine, expose `reachability.state/confidence` in SPL/API, enforce auto-suppress (<0.30) rules, and generate OpenVEX evidence blocks referencing graph hashes + runtime facts with policy thresholds. | Policy Guild (`src/Policy/StellaOps.Policy.Engine`, `docs/modules/policy/architecture.md`, `docs/reachability/function-level-evidence.md`) | | GAP-VEX-006 | TODO | Wire Policy/Excititor/UI/CLI surfaces so VEX emission and explain drawers show call paths, graph hashes, and runtime hits; add CLI `--evidence=graph`/`--threshold` plus Notify template updates. | Policy, Excititor, UI, CLI & Notify Guilds (`docs/modules/excititor/architecture.md`, `src/Cli/StellaOps.Cli`, `src/UI/StellaOps.UI`, `docs/09_API_CLI_REFERENCE.md`) | | GAP-DOC-008 | TODO | Publish the cross-module function-level evidence guide, update API/CLI references with the new `code_id` fields, and add OpenVEX/replay samples under `samples/reachability/**`. | Docs Guild (`docs/reachability/function-level-evidence.md`, `docs/09_API_CLI_REFERENCE.md`, `docs/api/policy.md`) | +| SYMS-BUNDLE-401-014 | TODO | Produce deterministic symbol bundles for air-gapped installs (`symbols bundle create|verify|load`), including DSSE manifests and Rekor checkpoints, and document offline workflows (`docs/specs/SYMBOL_MANIFEST_v1.md`). | Symbols Guild, Ops Guild (`src/Symbols/StellaOps.Symbols.Bundle`, `ops`) | > Use `docs/reachability/DELIVERY_GUIDE.md` for architecture context, dependencies, and acceptance tests. diff --git a/docs/implplan/SPRINT_505_ops_devops_iii.md b/docs/implplan/SPRINT_505_ops_devops_iii.md index a87951b29..a3c73581b 100644 --- a/docs/implplan/SPRINT_505_ops_devops_iii.md +++ b/docs/implplan/SPRINT_505_ops_devops_iii.md @@ -23,3 +23,5 @@ DEVOPS-OBS-51-001 | TODO | Implement SLO evaluator service (burn rate calculator DEVOPS-OBS-52-001 | TODO | Configure streaming pipeline (NATS/Redis/Kafka) with retention, partitioning, and backpressure tuning for timeline events; add CI validation of schema + rate caps. Dependencies: DEVOPS-OBS-51-001. | DevOps Guild, Timeline Indexer Guild (ops/devops) DEVOPS-OBS-53-001 | TODO | Provision object storage with WORM/retention options (S3 Object Lock / MinIO immutability), legal hold automation, and backup/restore scripts for evidence locker. Dependencies: DEVOPS-OBS-52-001. | DevOps Guild, Evidence Locker Guild (ops/devops) DEVOPS-OBS-54-001 | TODO | Manage provenance signing infrastructure (KMS keys, rotation schedule, timestamp authority integration) and integrate verification jobs into CI. Dependencies: DEVOPS-OBS-53-001. | DevOps Guild, Security Guild (ops/devops) +DEVOPS-SCAN-90-004 | TODO | Add a CI job that runs the scanner determinism harness against the release matrix (N runs per image), uploads `determinism.json`, and fails when score < threshold; publish artifact to release notes. Dependencies: SCAN-DETER-186-009/010. | DevOps Guild, Scanner Guild (ops/devops) +DEVOPS-SYMS-90-005 | TODO | Deploy Symbols.Server (Helm/Terraform), manage MinIO/Mongo storage, configure tenant RBAC/quotas, and wire ingestion CLI into release pipelines with monitoring and backups. Dependencies: SYMS-SERVER-401-011/013. | DevOps Guild, Symbols Guild (ops/devops) diff --git a/docs/implplan/SPRINT_513_provenance.md b/docs/implplan/SPRINT_513_provenance.md index 52ffee5d9..4f4c18d79 100644 --- a/docs/implplan/SPRINT_513_provenance.md +++ b/docs/implplan/SPRINT_513_provenance.md @@ -9,5 +9,6 @@ Task ID | State | Task description | Owners (Source) --- | --- | --- | --- PROV-OBS-53-001 | TODO | Implement DSSE/SLSA `BuildDefinition` + `BuildMetadata` models with canonical JSON serializer, Merkle digest helpers, and deterministic hashing tests. Publish sample statements for orchestrator/job/export subjects. | Provenance Guild (src/Provenance/StellaOps.Provenance.Attestation) PROV-OBS-53-002 | TODO | Build signer abstraction (cosign/KMS/offline) with key rotation hooks, audit logging, and policy enforcement (required claims). Provide unit tests using fake signer + real cosign fixture. Dependencies: PROV-OBS-53-001. | Provenance Guild, Security Guild (src/Provenance/StellaOps.Provenance.Attestation) +PROV-OBS-53-003 | TODO | Deliver `PromotionAttestationBuilder` that materialises the `stella.ops/promotion@v1` predicate (image digest, SBOM/VEX materials, promotion metadata, Rekor proof) and feeds canonicalised payload bytes to Signer via StellaOps.Cryptography. | Provenance Guild (src/Provenance/StellaOps.Provenance.Attestation) PROV-OBS-54-001 | TODO | Deliver verification library that validates DSSE signatures, Merkle roots, and timeline chain-of-custody, exposing reusable CLI/service APIs. Include negative-case fixtures and offline timestamp verification. Dependencies: PROV-OBS-53-002. | Provenance Guild, Evidence Locker Guild (src/Provenance/StellaOps.Provenance.Attestation) -PROV-OBS-54-002 | TODO | Generate .NET global tool for local verification + embed command helpers for CLI `stella forensic verify`. Provide deterministic packaging and offline kit instructions. Dependencies: PROV-OBS-54-001. | Provenance Guild, DevEx/CLI Guild (src/Provenance/StellaOps.Provenance.Attestation) \ No newline at end of file +PROV-OBS-54-002 | TODO | Generate .NET global tool for local verification + embed command helpers for CLI `stella forensic verify`. Provide deterministic packaging and offline kit instructions. Dependencies: PROV-OBS-54-001. | Provenance Guild, DevEx/CLI Guild (src/Provenance/StellaOps.Provenance.Attestation) diff --git a/docs/modules/policy/README.md b/docs/modules/policy/README.md index 03265da5a..c94dc5e34 100644 --- a/docs/modules/policy/README.md +++ b/docs/modules/policy/README.md @@ -23,6 +23,7 @@ Policy Engine compiles and evaluates Stella DSL policies deterministically, prod - Governance and scope mapping in ../../security/policy-governance.md. - Readiness briefs: ../policy/secret-leak-detection-readiness.md, ../policy/windows-package-readiness.md. - Readiness briefs: ../scanner/design/macos-analyzer.md, ../scanner/design/windows-analyzer.md, ../policy/secret-leak-detection-readiness.md, ../policy/windows-package-readiness.md. +- Ruby capability predicates design: ./design/ruby-capability-predicates.md. ## Backlog references - DOCS-POLICY-20-001 … DOCS-POLICY-20-012 (completed baseline). diff --git a/docs/modules/policy/TASKS.md b/docs/modules/policy/TASKS.md new file mode 100644 index 000000000..64306c2f9 --- /dev/null +++ b/docs/modules/policy/TASKS.md @@ -0,0 +1,5 @@ +# Policy Engine Guild — Active Tasks + +| Task ID | State | Notes | +| --- | --- | --- | +| `SCANNER-POLICY-0001` | DONE (2025-11-10) | Ruby component predicates implemented in engine/tests, DSL docs updated, offline kit verifies `seed-data/analyzers/ruby/git-sources`. | diff --git a/docs/modules/policy/design/ruby-capability-predicates.md b/docs/modules/policy/design/ruby-capability-predicates.md new file mode 100644 index 000000000..4657383f9 --- /dev/null +++ b/docs/modules/policy/design/ruby-capability-predicates.md @@ -0,0 +1,82 @@ +# Ruby Capability & Source Predicates (SCANNER-POLICY-0001) + +**Status:** Implemented · Owner: Policy Guild · Updated: 2025-11-10 +**Scope:** Extend Policy Engine DSL to consume Ruby analyzer metadata (`groups`, `declaredOnly`, capabilities, git/path provenance) emitted in Sprint 138. + +--- + +## 1. Goals + +1. Allow policies to express intent around Bundler groups (e.g., blocking `development` gems in production promotes). +2. Expose Ruby capability evidence (exec/net/serialization/job schedulers) as first-class predicates. +3. Differentiate package provenance: registry, git, path/vendor cache. +4. Ensure new predicates work in offline/air-gapped evaluation and export deterministically. + +Non-goals: UI wiring (handled by Policy Studio team), policy templates rollout (tracked separately in DOCS-POLICY backlog). + +## 2. Source Metadata + +Scanner now emits the following fields per Ruby component: + +| Field | Type | Example | Notes | +|-------|------|---------|-------| +| `groups` | `string` (semi-colon list) | `development;test` | Aggregated from manifest + lockfile. | +| `declaredOnly` | `bool` (string `"true"/"false"`) | `"false"` | False indicates vendor cache evidence present. | +| `source` | `string` | `git:https://github.com/example/git-gem.git@` | Registry (`https://`), `git:`, `path:`, `vendor-cache`. | +| `artifact` | `string?` | `vendor/cache/path-gem-2.1.3.gem` | Only when cached artefact observed. | +| Capability flags | `string -> bool` | `capability.exec = "true"` etc. | Includes scheduler sub-keys. | + +## 3. Proposed Predicates + +| Predicate | Signature | Description | +|-----------|-----------|-------------| +| `ruby.group(name: string)` | `bool` | True if component belongs to Bundler group `name`. | +| `ruby.groups()` | `set` | Returns all groups for aggregations. | +| `ruby.declared_only()` | `bool` | True when component has no vendor/installed evidence. | +| `ruby.source(kind?: string)` | `bool` | Kind matches prefix (`registry`, `git`, `path`, `vendor-cache`). | +| `ruby.capability(name: string)` | `bool` | Supported names: `exec`, `net`, `serialization`, `scheduler`, scheduler subtypes (`scheduler.activejob`, etc.). | +| `ruby.capability_any(names: set)` | `bool` | Utility predicate to check multiple capabilities. | + +Implementation detail: compile-time validation ensures predicate usage only within Ruby component scope (similar to `node.group` pattern). + +## 4. DSL & Engine Changes + +1. **Schema mapping:** Update `ComponentFacts` model to surface new Ruby metadata in evaluation context. +2. **Predicate registry:** Add Ruby-specific predicate handlers to `PolicyPredicateRegistry` with deterministic ordering. +3. **Explain traces:** Include matched predicates + metadata in explain output. +4. **Exports:** Ensure Offline Kit bundles include updated predicate metadata (no runtime fetch). + +## 5. Policy Templates (follow-up) + +Create sample rules under `policy/templates/ruby`: + +- Block `ruby.group("development")` when `promotion.target == "prod"`. +- Flag `ruby.capability("exec")` components unless allowlisted. +- Require `ruby.source("git")` packages to provide pinned hash allowlists. + +Tracking: DOCS-POLICY follow-up (not part of SCANNER-POLICY-0001 initial kick-off). + +## 6. Testing Strategy + +- Unit tests for each predicate (true/false cases, unsupported values). +- Integration test tying sample Scanner payload to simulated policy evaluation. +- Determinism run: repeated evaluation with same snapshot must yield identical explain trace hash. +- Offline regression: ensure `seed-data/analyzers/ruby/git-sources` fixture flows through offline-kit policy evaluation script. + +## 7. Timeline & Dependencies + +| Step | Owner | Target | +|------|-------|--------| +| Predicate implementation + tests | Policy Engine Guild | Sprint 138 (in progress) | +| Offline kit regression update | Policy + Ops | Sprint 138 | +| Policy templates & docs | Docs Guild | Sprint 139 | + +Dependencies: Scanner metadata in place (SCANNER-ENG-0016 DONE); no additional service contracts required. + +## 8. Open Questions + +1. Should `declaredOnly` interact with existing waiver semantics (e.g., treat as lower severity)? → Needs risk review. +2. Do we expose scheduler sub-types individually or aggregate under `ruby.capability("scheduler")` only? → Proposed to expose both for flexibility. +3. Is git URL normalization required (strip credentials, hash fragments)? → Ensure sanitization before evaluation. + +Please comment in `docs/modules/policy/design/ruby-capability-predicates.md` or via SCANNER-POLICY-0001 sprint entry. diff --git a/docs/modules/scanner/design/ruby-analyzer.md b/docs/modules/scanner/design/ruby-analyzer.md index b8881e51e..780ffa368 100644 --- a/docs/modules/scanner/design/ruby-analyzer.md +++ b/docs/modules/scanner/design/ruby-analyzer.md @@ -1,6 +1,6 @@ # Ruby Analyzer Parity Design (SCANNER-ENG-0009) -**Status:** Draft • Owner: Ruby Analyzer Guild • Updated: 2025-11-02 +**Status:** Implemented • Owner: Ruby Analyzer Guild • Updated: 2025-11-10 ## 1. Goals & Non-Goals - **Goals** @@ -70,10 +70,9 @@ ### 4.4 Runtime Graph Builder - Static analysis for `require`, `require_relative`, `autoload`, Zeitwerk conventions, and Rails initialisers. - Implementation phases: - 1. Parse AST using tree-sitter Ruby embedded under `StellaOps.Scanner.Analyzers.Lang.Ruby.Syntax` with deterministic bindings. - 2. Generate edges `entrypoint -> file` and `file -> package` with reason codes (`require-static`, `autoload-zeitwerk`, `autoload-const_missing`). - 3. Identify framework entrypoints (Rails controllers, Rack middleware, Sidekiq workers) via heuristics defined in `SCANNER-ANALYZERS-RUBY-28-*` tasks. -- Output merges with EntryTrace usage hints to support runtime filtering in Policy Engine. + 1. **MVP (shipped in Sprint 138):** perform lightweight scanning using deterministic regex patterns scoped to Ruby sources. Captures explicit `require*` and `autoload` statements, records referencing files, and links back to packages when a matching lock entry exists. + 2. **Planned follow-up:** integrate tree-sitter Ruby under `StellaOps.Scanner.Analyzers.Lang.Ruby.Syntax` for full AST coverage (Zeitwerk constants, conditional requires, dynamic module loading). This phase remains tracked under SCANNER-ANALYZERS-RUBY-28-003. +- Output merges with EntryTrace usage hints to support runtime filtering in Policy Engine. Entrypoint detection currently keys off file location plus usage hints; richer framework-aware mapping will accompany the tree-sitter phase. ### 4.5 Capability & Surface Signals - Emit evidence documents for: @@ -95,11 +94,13 @@ | `ruby_packages.json` | Array `{id, name, version, source, provenance, groups[], platform}` | SBOM Composer, Policy Engine | | `ruby_runtime_edges.json` | Edges `{from, to, reason, confidence}` | EntryTrace overlay, Policy explain traces | | `ruby_capabilities.json` | Capability `{kind, location, evidenceHash, params}` | Policy Engine (capability predicates) | +| `ruby_observation.json` | Summary document (packages, runtime edges, capability flags) | Surface manifest, Policy explain traces | All records follow AOC appender rules (immutable, tenant-scoped) and include `hash`, `layerDigest`, and `timestamp` normalized to UTC ISO-8601. ## 6. Testing Strategy - **Fixtures**: Extend `fixtures/lang/ruby` with Rails, Sinatra, Sidekiq, Rack, container images (with/without vendor cache). +- **Fixtures**: Added `git-sources` scenario covering git/path dependencies, bundler groups, and vendor cache evidence for declared-only toggling. - **Determinism**: Golden snapshots for package lists and capability outputs across repeated runs. - **Integration**: Worker e2e to ensure per-layer aggregation; CLI golden outputs (`stella ruby inspect`). - **Policy**: Unit tests verifying new predicates (`ruby.group`, `ruby.capability.exec`, etc.) in Policy Engine test suite. @@ -121,15 +122,15 @@ All records follow AOC appender rules (immutable, tenant-scoped) and include `ha - Need alignment with Export Center on Ruby-specific manifest emissions. ## 9. Licensing & Offline Packaging (SCANNER-LIC-0001) -- **License**: tree-sitter core and `tree-sitter-ruby` grammar are MIT licensed (confirmed via upstream LICENSE files retrieved 2025-11-02). +- **License**: tree-sitter core and `tree-sitter-ruby` grammar are MIT licensed (confirmed via upstream LICENSE files retrieved 2025-11-10). - **Obligations**: - 1. Include both MIT license texts in `/third-party-licenses/` and in Offline Kit manifests. - 2. Update `NOTICE.md` to acknowledge embedded grammars per company policy. - 3. Record the grammar commit hashes in build metadata; regenerate generated C/WASM artifacts deterministically. - 4. Ensure build pipeline uses `tree-sitter-cli` only as a build-time tool (not redistributed) to avoid extra licensing obligations. + 1. Keep MIT license texts in `/third-party-licenses/` and ship them with Offline Kits (fulfilled via `build_offline_kit.py` copying the directory into staging). + 2. Track acknowledgements in `NOTICE.md` (completed). + 3. Record grammar provenance in build metadata once native parsers ship; current MVP uses regex-only parsing and does **not** bundle tree-sitter artifacts yet, so no generated sources are redistributed. + 4. When tree-sitter integration lands, ensure `tree-sitter-cli` remains a build-time tool only. - **Deliverables**: - - SCANNER-LIC-0001 to capture Legal sign-off and update packaging scripts. - - Export Center to mirror license files into Offline Kit bundle. + - SCANNER-LIC-0001 tracks Legal sign-off; Offline Kit packaging now mirrors `third-party-licenses/`. + - Export centre recipe inherits the copied directory with deterministic hashing. --- *References:* diff --git a/docs/modules/scanner/determinism-score.md b/docs/modules/scanner/determinism-score.md new file mode 100644 index 000000000..a8805a4b9 --- /dev/null +++ b/docs/modules/scanner/determinism-score.md @@ -0,0 +1,87 @@ +# Scanner Determinism Score Guide + +> **Status:** Draft – Sprint 186/202/203 +> **Owners:** Scanner Guild · QA Guild · DevEx/CLI Guild · DevOps Guild + +## 1. Goal + +Quantify how repeatable a scanner release is by re-running scans under frozen conditions and reporting the ratio of bit-for-bit identical outputs. The determinism score lets customers and auditors confirm that Stella Ops scans are replayable and trustworthy. + +## 2. Test harness overview (`SCAN-DETER-186-009`) + +1. **Inputs:** image digests, policy bundle SHA, feed snapshot SHA, scanner container digest, platform (linux/amd64 by default). +2. **Execution loop:** run the scanner *N* times (default 10) with: + * `--fixed-clock ` + * `RNG_SEED=1337` + * `SCANNER_MAX_CONCURRENCY=1` + * feeds/policy tarballs mounted read-only + * `--network=none`, `--cpuset-cpus=0`, `--memory=2G` +3. **Canonicalisation:** normalise JSON outputs (SBOM, VEX, findings, logs) using the same serializer as production (`StellaOps.Scanner.Replay` helpers). +4. **Hashing:** compute SHA-256 for each canonical artefact per run. +5. **Score calculation:** `identical_runs / total_runs` (per image and overall). A run is “identical” if all artefact hashes match the baseline (run 1). + +The harness persists the full run set under CAS, allowing regression tests and Offline kit inclusion. + +## 3. Output artefacts (`SCAN-DETER-186-010`) + +* `determinism.json` – per-image runs, identical counts, score, policy/feed hashes. +* `run_i/*.json` – canonicalised outputs for debugging. +* `diffs/` – optional diff samples when runs diverge. + +Example `determinism.json`: + +```json +{ + "release": "scanner-0.14.3", + "platform": "linux/amd64", + "policy_sha": "a1b2c3…", + "feeds_sha": "d4e5f6…", + "images": [ + { + "digest": "sha256:abc…", + "runs": 10, + "identical": 10, + "score": 1.0, + "artifact_hashes": { + "sbom.cdx.json": "sha256:11…", + "vex.json": "sha256:22…", + "findings.json": "sha256:33…" + } + } + ], + "overall_score": 1.0 +} +``` + +## 4. CI integration (`DEVOPS-SCAN-90-004`) + +* GitHub/Gitea pipeline stages run the determinism harness for the release matrix. +* Fail the job when `overall_score < threshold` (default 0.95) or any image falls below 0.90. +* Upload `determinism.json` and artefacts as build outputs; attach to release notes and Offline kits. + +## 5. CLI support (`CLI-DETER-70-003/004`) + +* `stella detscore run` – executes the harness locally, honoring the same frozen-clock and seed settings; exits non-zero when score falls below the configured threshold. +* `stella detscore report` – summarises one or more `determinism.json` files for release notes, showing per-image scores and detection of non-deterministic artefacts. + +## 6. Policy & UI consumption + +* Policy Engine can enforce determinism thresholds (e.g., block promotion if score < 0.95) using the `determinism.json` evidence. +* UI surfaces the score alongside scans (e.g., badge in scan detail view) referencing task `UI-SBOM-DET-01`. + +## 7. Evidence & replay + +* Include `determinism.json` and canonical run outputs in Replay bundles (`docs/replay/DETERMINISTIC_REPLAY.md`). +* DSSE-sign determinism results before adding them to Evidence Locker. + +## 8. Implementation checklist + +| Area | Task ID | Notes | +|------|---------|-------| +| Harness | `SCAN-DETER-186-009` | Deterministic execution + hashing | +| Artefacts | `SCAN-DETER-186-010` | Publish JSON, CAS storage | +| CLI | `CLI-DETER-70-003/004` | Local runs + reporting | +| DevOps | `DEVOPS-SCAN-90-004` | CI enforcement | +| Docs | `DOCS-DETER-70-002` | (this document) | + +Update this guide with links to code once tasks move to **DONE**. diff --git a/docs/modules/scanner/entropy.md b/docs/modules/scanner/entropy.md new file mode 100644 index 000000000..dfebb56a9 --- /dev/null +++ b/docs/modules/scanner/entropy.md @@ -0,0 +1,126 @@ +# Entropy Analysis for Executable Layers + +> **Status:** Draft – Sprint 186/209 +> **Owners:** Scanner Guild · Policy Guild · UI Guild · Docs Guild + +## 1. Overview + +Entropy analysis highlights opaque regions inside container layers (packed binaries, stripped blobs, embedded firmware) so Stella Ops can prioritise artefacts that are hard to audit. The scanner computes per-file entropy metrics, reports opaque ratios per layer, and feeds penalties into the trust algebra. + +## 2. Scanner pipeline (`SCAN-ENTROPY-186-011/012`) + +* **Target files:** ELF, PE/COFF, Mach-O executables and large raw blobs (>16 KB). Archive formats (zip/tar) are unpacked by existing analyzers before entropy processing. +* **Section analysis:** + * ELF – `.text`, `.rodata`, `.data`, custom sections. + * PE – section table entries (`IMAGE_SECTION_HEADER`). + * Mach-O – LC_SEGMENT/LC_SEGMENT_64 sections. +* **Sliding window:** 4 KB window with 1 KB stride. Entropy calculated using Shannon entropy: + + \[ + H = -\sum_{i=0}^{255} p_i \log_2 p_i + \] + + Windows with `H ≥ 7.2` bits/byte are marked “opaque”. +* **Heuristics & hints:** + * Flag entire files with no symbols or stripped debug info. + * Detect known packer section names (`.UPX*`, `.aspack`, etc.). + * Record offsets, window sizes, and entropy values to support explainability. +* **Outputs:** + * `entropy.report.json` (per-file details, windows, hints). + * `layer_summary.json` (opaque byte ratios per layer and overall image). + * Penalty score contributed to the trust algebra (`entropy_penalty`). + +All JSON output is canonical (sorted keys, UTF-8) and included in DSSE attestations/replay bundles. + +## 3. JSON Schemas + +### 3.1 `entropy.report.json` + +```jsonc +{ + "schema": "stellaops.entropy/report@1", + "imageDigest": "sha256:…", + "layerDigest": "sha256:…", + "files": [ + { + "path": "/opt/app/libblob.so", + "size": 5242880, + "opaqueBytes": 1342177, + "opaqueRatio": 0.25, + "flags": ["stripped", "section:.UPX0"], + "windows": [ + { "offset": 0, "length": 4096, "entropy": 7.45 }, + { "offset": 1024, "length": 4096, "entropy": 7.38 } + ] + } + ] +} +``` + +### 3.2 `layer_summary.json` + +```jsonc +{ + "schema": "stellaops.entropy/layer-summary@1", + "imageDigest": "sha256:…", + "layers": [ + { + "digest": "sha256:layer4…", + "opaqueBytes": 2306867, + "totalBytes": 10485760, + "opaqueRatio": 0.22, + "indicators": ["packed", "no-symbols"] + } + ], + "imageOpaqueRatio": 0.18, + "entropyPenalty": 0.12 +} +``` + +## 4. Policy integration (`POLICY-RISK-90-001`) + +* Policy Engine receives `entropy_penalty` and per-layer ratios via scan evidence. +* Default thresholds: + * Block when `imageOpaqueRatio > 0.15` and provenance unknown. + * Warn when any executable has `opaqueRatio > 0.30`. +* Penalty weights are configurable per tenant. Policy explanations include: + * Highest-entropy files and offsets. + * Reason code (packed, no symbols, runtime reachable). + +## 5. UI experience (`UI-ENTROPY-40-001/002`) + +* **Heatmaps:** render entropy along the file timeline (green → red). +* **Layer donut:** show opaque % per layer with tooltips linking to file list. +* **“Why risky?” chips:** highlight triggers such as *Packed-like*, *Stripped*, *No symbols*. +* Policy banners explain configured thresholds and mitigation (add provenance, unpack, or accept risk). +* Provide direct download links to `entropy.report.json` for audits. + +## 6. CLI / API hooks + +* CLI – `stella scan artifacts --entropy` option prints top opaque files and penalties. +* API – `GET /api/v1/scans/{id}/entropy` serves summary + evidence references. +* Notify templates can include entropy penalties to escalate opaque images. + +## 7. Trust algebra + +The penalty is computed as: + +\[ +\text{entropyPenalty} = K \sum_{\text{layers}} \left( \frac{\text{opaqueBytes}}{\text{totalBytes}} \times \frac{\text{layerBytes}}{\text{imageBytes}} \right) +\] + +* Default `K = 0.5`. +* Cap penalty at 0.3 to avoid over-weighting tiny blobs. +* Combine with other trust signals (reachability, provenance) to prioritise audits. + +## 8. Implementation checklist + +| Area | Task ID | Notes | +|------|---------|-------| +| Scanner analysis | `SCAN-ENTROPY-186-011` | Sliding window entropy & heuristics | +| Evidence output | `SCAN-ENTROPY-186-012` | JSON reports + DSSE | +| Policy integration | `POLICY-RISK-90-001` | Trust weight + explanations | +| UI | `UI-ENTROPY-40-001/002` | Visualisation & messaging | +| Docs | `DOCS-ENTROPY-70-004` | (this guide) | + +Update this document as thresholds change or additional packer signatures are introduced. diff --git a/docs/policy/dsl.md b/docs/policy/dsl.md index 49a82b04c..7e6b0833b 100644 --- a/docs/policy/dsl.md +++ b/docs/policy/dsl.md @@ -167,7 +167,8 @@ Missing fields evaluate to `null`, which is falsey in boolean context and propag | `vex.latest()` | `→ Statement` | Lexicographically newest statement. | | `advisory.has_tag(tag)` | `string → bool` | Checks advisory metadata tags. | | `advisory.matches(pattern)` | `string → bool` | Glob match against advisory identifiers. | -| `sbom.has_tag(tag)` | `string → bool` | Uses SBOM inventory tags (usage vs inventory). | +| `sbom.has_tag(tag)` | `string → bool` | Uses SBOM inventory tags (usage vs inventory). | +| `sbom.any_component(predicate)` | `(Component → bool) → bool` | Iterates SBOM components, exposing `component` plus language scopes (e.g., `ruby`). | | `exists(expression)` | `→ bool` | `true` when value is non-null/empty. | | `coalesce(a, b, ...)` | `→ value` | First non-null argument. | | `days_between(dateA, dateB)` | `→ int` | Absolute day difference (UTC). | @@ -180,12 +181,29 @@ Missing fields evaluate to `null`, which is falsey in boolean context and propag | `secret.path.allowlist(patterns)` | `list → bool` | True when all findings fall within allowed path patterns (useful for waivers). | All built-ins are pure; if inputs are null the result is null unless otherwise noted. - ---- - -## 7 · Rule Semantics - -1. **Ordering:** Rules execute in ascending `priority`. When priorities tie, lexical order defines precedence. + +--- + +### 6.1 · Ruby Component Scope + +Inside `sbom.any_component(...)`, Ruby gems surface a `ruby` scope with the following helpers: + +| Helper | Signature | Description | +|--------|-----------|-------------| +| `ruby.group(name)` | `string → bool` | Matches Bundler group membership (`development`, `test`, etc.). | +| `ruby.groups()` | `→ set` | Returns all groups for the active component. | +| `ruby.declared_only()` | `→ bool` | `true` when no vendor cache artefacts were observed for the gem. | +| `ruby.source(kind?)` | `string? → bool` | Returns the raw source when called without args, or matches provenance kinds (`registry`, `git`, `path`, `vendor-cache`). | +| `ruby.capability(name)` | `string → bool` | Checks capability flags emitted by the analyzer (`exec`, `net`, `scheduler`, `scheduler.activejob`, etc.). | +| `ruby.capability_any(names)` | `set → bool` | `true` when any capability in the set is present. | + +Scheduler capability sub-types use dot notation (`ruby.capability("scheduler.sidekiq")`) and inherit from the broad `scheduler` capability. + +--- + +## 7 · Rule Semantics + +1. **Ordering:** Rules execute in ascending `priority`. When priorities tie, lexical order defines precedence. 2. **Short-circuit:** Once a rule sets `status`, subsequent rules only execute if they use `combine`. Use this sparingly to avoid ambiguity. 3. **Actions:** - `status := ` – Allowed values: `affected`, `not_affected`, `fixed`, `suppressed`, `under_investigation`, `escalated`. diff --git a/docs/release/promotion-attestations.md b/docs/release/promotion-attestations.md new file mode 100644 index 000000000..2639d3046 --- /dev/null +++ b/docs/release/promotion-attestations.md @@ -0,0 +1,111 @@ +# Promotion-Time Attestations for Stella Ops + +> **Status:** Draft – sprint 186/202/203 coordination +> **Owners:** Signing Guild · Provenance Guild · DevEx/CLI Guild · Export Center Guild + +## 1. Purpose + +Capture the full promotion-time evidence – image digest, SBOM/VEX artifacts, Rekor proof – in a single DSSE-wrapped statement so that air-gapped auditors can verify releases without talking to external services. This document explains the data shape, producer responsibilities, and downstream consumers that rely on the promotion attestation. + +## 2. Predicate schema – `stella.ops/promotion@v1` + +```jsonc +{ + "_type": "stella.ops/promotion@v1", + "subject": [ + { "name": "registry.example.com/acme/api", "digest": { "sha256": "…" } } + ], + "materials": [ + { "role": "sbom", "algo": "sha256", "digest": "…", "format": "CycloneDX-1.6", "uri": "oci://…/sbom@sha256:…" }, + { "role": "vex", "algo": "sha256", "digest": "…", "format": "OpenVEX-1.0", "uri": "oci://…/vex@sha256:…" } + ], + "promotion": { + "from": "staging", + "to": "prod", + "actor": "ci/gitlab-runner", + "timestamp": "2025-11-10T12:34:56Z", + "pipeline": "https://git.example.com/acme/api/-/pipelines/12345" + }, + "rekor": { + "uuid": "REKOR_ENTRY_UUID", + "logIndex": 1234567, + "inclusionProof": { + "rootHash": "MERKLE_ROOT", + "hashes": ["…path…"], + "treeSize": 9876543, + "checkpoint": { + "origin": "rekor.sigstore.dev - transparency log", + "size": 9876543, + "hash": "CHECKPOINT_HASH", + "signedNote": "BASE64_NOTE" + } + } + } +} +``` + +The Provenance Guild implements the predicate builder (task `PROV-OBS-53-003`). The signer pipeline accepts the predicate as a raw JSON payload and wraps it inside a DSSE envelope (`SIGN-CORE-186-005`). Rekor metadata is pulled from Attestor after DSSE submission. + +## 3. Producer workflow + +### 3.1 CLI orchestration (`CLI-PROMO-70-001/002`) + +1. Resolve and freeze the image digest (`cosign triangulate`/`crane digest`). +2. Hash SBOM and VEX artifacts, optionally publish them to an OCI registry. +3. Upload the SBOM (or dummy artifact) to Rekor to obtain `{uuid, logIndex}`. +4. Retrieve inclusion proof + checkpoint (`rekor-cli get`, `rekor-cli loginfo`). +5. Build `attestation.json` using the template above and current promotion metadata. +6. Call Signer to produce a DSSE bundle (`cosign attest` or `stella promotion attest`). +7. Store the bundle alongside `attestation.json` and add both to Offline/Replay kits. + +### 3.2 Signer responsibilities (`SIGN-CORE-186-004/005/006`) + +* Accept the promotion predicate, verify Proof-of-Entitlement + release integrity. +* Sign via StellaOps.Cryptography providers (keyless or KMS) and return DSSE+cert bundle. +* Emit audit entries referencing the promotion metadata and Rekor proof. + +### 3.3 Export Center integration (`EXPORT-OBS-54-002`) + +* Bundle `attestation.json`, DSSE envelope, and Rekor checkpoint inside Offline kits. +* Surface promotion evidence via API/CLI for air-gapped consumers. + +## 4. Verification flow + +Auditors can validate the promotion attestation offline: + +1. Verify the DSSE signature using the provided bundle and trusted key/cert chain. +2. Recompute Merkle inclusion using the embedded proof + checkpoint. The checkpoint’s signed note ties the inclusion to a known Rekor tree size. +3. Hash SBOM/VEX artifacts and compare to the `materials` digests. +4. Confirm the promotion metadata in release notes/CI evidence. + +Authority exposes helper APIs (`AUTH-VERIFY-186-007`) to replay both DSSE and Merkle validations. + +## 5. APIs & storage + +| Component | Endpoint / Artifact | Notes | +|------------------|--------------------------------------------------|-------| +| Signer | `POST /api/v1/signer/sign/dsse` | Accepts promotion predicate, returns DSSE bundle + auditId. | +| Attestor | `POST /api/v1/rekor/entries` | Persists DSSE, returns `{uuid, index, proof}`. | +| Export Center | `GET /api/v1/exports/{id}/promotion` (planned) | Serve promotion attestation + bundle. | +| Evidence Locker | Store DSSE + Rekor proof for long-term retention. | + +Artifacts are content-addressed via CAS and mirrored into Offline kits (`docs/replay/DETERMINISTIC_REPLAY.md`). + +## 6. Security considerations + +* Promotion metadata is tenant-scoped; aim to avoid leaking pipeline URLs across tenants. +* Rekor inclusion proofs must be fetched at promotion time and embedded; do **not** rely on on-demand Rekor access in air-gapped installs. +* Rotate signing keys via Authority/KMS; promotion attestation inherits Signer’s DSSE trust model. + +## 7. Implementation checklist + +| Area | Sprint task | Status | +|------|-------------|--------| +| Predicate builder | `PROV-OBS-53-003` | TODO | +| Signer support | `SIGN-CORE-186-004/005/006` | TODO | +| CLI commands | `CLI-PROMO-70-001/002` | TODO | +| Authority verifier | `AUTH-VERIFY-186-007` | TODO | +| Export packaging | `EXPORT-OBS-54-002` | TODO | +| Documentation | `DOCS-PROMO-70-001` | TODO | + +When all tasks are completed this document should be updated with status links and sample payloads. diff --git a/docs/specs/SYMBOL_MANIFEST_v1.md b/docs/specs/SYMBOL_MANIFEST_v1.md new file mode 100644 index 000000000..a812b7c49 --- /dev/null +++ b/docs/specs/SYMBOL_MANIFEST_v1.md @@ -0,0 +1,121 @@ +# Symbol Manifest v1 Specification + +> **Status:** Draft – Sprint 401 (Symbols Server rollout) +> **Owners:** Symbols Guild · Scanner Guild · Runtime Signals Guild · DevOps Guild + +## 1. Purpose + +Provide a deterministic manifest format for publishing debug symbols, source maps, and runtime lookup metadata. Manifests are DSSE-signed and optionally logged to Rekor so Scanner.Symbolizer and runtime probes can resolve functions in air-gapped or sovereign environments. + +## 2. Manifest structure + +```json +{ + "schema": "stellaops.symbols/manifest@v1", + "artifactDigest": "sha256:…", // build or container digest + "entries": [ + { + "debugId": "3b2d…ef", + "os": "linux", + "arch": "amd64", + "format": "dwarf", + "hash": "sha256:…", // hash of blob archive + "path": "symbols/3b/2d/…/index.zip", + "size": 1234567, + "metadata": { + "lang": "c++", + "compiler": "clang-16" + } + } + ], + "sourceMaps": [ + { + "asset": "app.min.js", + "debugId": "sourcemap:…", + "hash": "sha256:…", + "path": "maps/app.min.js.map" + } + ], + "toolchain": { + "name": "gha@actions", + "version": "2025.11.10", + "builderId": "urn:stellaops:builder:release" + }, + "provenance": { + "timestamp": "2025-11-10T09:00:00Z", + "attestor": "stellaops-ci", + "reproducible": true + } +} +``` + +* `schema` is fixed to `stellaops.symbols/manifest@v1`. +* `entries` covers ELF/PE/Mach-O debug bundles; `sourceMaps` is optional. +* Paths are relative to the blob store root (e.g., MinIO bucket). DSSE signatures cover the canonical JSON (sorted keys, minified). + +## 3. Canonical keys per platform + +| Platform | `debugId` derivation | Notes | +|----------|---------------------|-------| +| ELF | NT_GNU_BUILD_ID (`.note.gnu.build-id`) or SHA-256 of `.text` as fallback | Task `SYMS-CLIENT-401-012` | +| PE/COFF | `pdbGuid:pdbAge` from CodeView debug directory | Portable PDB preferred | +| Mach-O | LC_UUID | Use corresponding dSYM when available | +| JVM | JAR SHA-256 + class/method signature triple | ASM-based scanner | +| Node/TS | Asset SHA-256 + sourceMap URL | Includes sourcemap content | +| Go/Rust/C++ | DWARF CU UUID or binary digest + address ranges | Handles stripped symbols | + +Derivers live in `IPlatformKeyDeriver` implementations. + +## 4. Upload & verification (`SYMS-INGEST-401-013`) + +1. CI builds debug artefacts (PDB/dSYM/ELF DWARF, sourcemaps). +2. `symbols ingest` CLI: + * Normalises manifest JSON (sorted keys, minified). + * Signs the manifest via DSSE (keyless or KMS per tenant). + * Uploads blobs to MinIO/S3 using deterministic prefixes: `symbols/{tenant}/{os}/{arch}/{debugId}/…`. + * Calls `POST /v1/symbols/upload` with the signed manifest and metadata. + * Submits manifest DSSE to Rekor (optional but recommended). +3. Symbols.Server validates DSSE, stores manifest metadata in MongoDB (`symbol_index` collection), and publishes gRPC/REST lookup availability. + +## 5. Resolve APIs (`SYMS-SERVER-401-011`) + +* `GET /v1/symbols/resolve?tenant=…&os=…&arch=…&debugId=…` + Returns blob location, hashes, and manifest metadata (sanitised per tenancy). +* `POST /v1/lookup/addresses` + Input: `{ debugId, addresses: [0x401000, …] }` + Output: `[{ addr, function, file, line }]`. +* `GET /v1/manifests/by-artifact/:digest` + Lists all debug IDs published for a build or image digest. + +All lookups require OpTok scopes (`symbols.resolve`). Multi-tenant filtering is enforced at the query level. + +## 6. Runtime proxy & caching + +* Optional `Symbols.Proxy` sidecar runs near runtime probes, caching resolve results on disk with TTL/cap. +* Scanner.Symbolizer and runtime probes first check local LRU caches before hitting the server, falling back to Offline bundles in air-gap mode. + +## 7. Offline bundles (`SYMS-BUNDLE-401-014`) + +* `symbols bundle create` generates a TAR archive with: + * DSSE-signed `SymbolManifest v1`. + * Blob archives (zip/tar). + * Rekor checkpoints (if present). +* Bundles are content-addressed (CAS prefix `reachability/symbols/…`) and signed before distribution. + +## 8. Security considerations + +* Enforce per-tenant bucket prefixes; optionally replicate “public” symbol sets for vendor-supplied packages. +* DSSE + Rekor ensure tamper detection; Authority manages key rotation routes (GOST/SM/eIDAS) for sovereign deployments. +* Reject uploads where `hash` mismatch or `artifactDigest` not tied to known release pipelines. + +## 9. Related tasks + +| Area | Task ID | Notes | +|------|---------|-------| +| Server | `SYMS-SERVER-401-011` | REST/gRPC microservice | +| Client | `SYMS-CLIENT-401-012` | SDK + key derivation | +| CLI | `SYMS-INGEST-401-013` | DSSE-signed manifest upload | +| Offline bundles | `SYMS-BUNDLE-401-014` | Air-gap support | +| Docs | `DOCS-SYMS-70-003` | (this document) | + +Future revisions (`@v2`) will extend the manifest with packer classification hints and reachability graph references. diff --git a/ops/offline-kit/__pycache__/build_offline_kit.cpython-312.pyc b/ops/offline-kit/__pycache__/build_offline_kit.cpython-312.pyc index 356d4793cd78a0fa53543851b41c6804c9897cb0..3d7e3fd9ae054b62da253539b4a006c33db16785 100644 GIT binary patch delta 8625 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 sXt#M@<2a%X+uVK+8|n@VJNn0~RKxC;$Ke delta 4821 zcmai13viUx75@KyW_Od_ESm?LO~@vj1ePQl2mz8nNPs*Dk_19f8VJi~@1L;AKEnO~ zg=AStcvP(&AI$BjQ*lN+jI~CwG(M`eW3?8wIO@oBTK~~%ovGDvs-3B?!H(@Y_h*y9 zw9_B*-E+@5_uPBVJ@?$ZFI*H~`H5)1l#^o>;CuhXcZa@iI%8idt~|5+%Nm^^l}N?2 zB8mh-${yROQH$}2NXt>e4rjZ>R(8UXXTV|XL9uY!7WKx;2FqgQgXJ;rpjQ-#Nc2(P zpilCU?37Iqh!t_LV!oflxpvO2ypHSO+$yl{B)OSI@t*UV-jYm@73u8LB=4+YaJf$C z5=j1OffPt9X7$?o2mM43{|e9QNG(}<+BCS51c>{zVX&Iiks@eo$SUH2wwA0W#n1*w zJt={sfcZ^iC79m`R4b_l zszusFYQ|Rx0?mOCw!&Y_?A2|}gzuy{rT4e7NA<6N91|xEU-UzZDHkZ>YRnasT*z>Lm2=!lFdB!84aFAVb z?bVj01cD$V46%Fj1NlQpDgZnIAo^bLoT>T6#xyVwv2XK3 zu&F}@-|1GMsw(((6kSpEqmlv(H9{!qRK^_;(9P_WyKd7_Amu`U8R2=~FQpeE&qiJ~ zy|uId@}}LFn+ImJ?St|9acPQj%a-}Ap~c+%4L`dVs{V zqG2{sRM^j*<9^IL=g*;&s4#_)LO70a8bHgdMJaF!nKmUv!J!~cBoum*y;HOm0$bu) zW;_GZ!*gw(bs~h}Xz{z^K1>n%eSiUNTe7JUIVTWqMaV+n>%%~4VZR+IzR>wKa!! zSvOBP-9iv#9XA8}`=YnN_yBYsWbb+#K-*d7Hhu;Dds$g|0qZDx&oCwUE$p81vOOt_ zEJ;v)LrO?BkVIJ4+)g>`8-jhEojbaFJAyZK4+LVwucw%XbPNG~g;c@Vt64Y<6L(<& zt-ccLIgnvRlaI3z-*Mv~4Et%t+}Zk+Siv`QD-Jctk(A`Osd`yb=q<=G($Q#Qm>y=$ z4J&qIO#mGwq^M>%DO}m8O=^}V!_g#>=)Isrann>im7exB8EZ>2;;$F zOqfrhZ(#D~SxEQNEpBlgYufS*bF>vKm90t+2>FMu;wK7R7tN!<=0O?D38Q$C~*;R+z4VPq&U^Bgba;aA&F6p0-oF(!!cM zJ&Q(||L2HGv4y?f>9MtDtP?vs*jZqm6y+*W;C_KG;MZsE1;q*;d;Vp%0vs}q=!he2 zWw~8>b|7=p*?`Wpb*$seTOQ`>bg}bYX7IwrUhQ&;8DIvBX0xk6AO@yH>b)iPDp?HrC62 zzAcZHce})0e9ODLi>nuWU#7HVG}G41LykLT11yEO-rZdY5qqS&CycR36)wEpY7-+P zaAu|2@rY8(uf~*FdmS~W>;X8(fzedp>T~HgFqXc6uyEQ8^dn^a4dG*iPXxAi`zhN~ zNb}nBH2Y|Ko9$Wb+Yo-uR^L!FjS21d^NU0^Q%O!lk4W0J`YtNr8G%AN9+?QrvBbDU z@j=61N)#^{>Oi>ee#F-WJ%fO^7R9@OUP91HAW|P7V1-{ou^R9yz^f&kWtP2T+ce&9 zy!@(`;bfddrC@|;mH2U#oJZhyGZtq0BEm}uzXkBSR0CZ3^1|&%xi7Dw!bOC?BfJ8j z=3NbYFcykOMkKsw4u$A@CRF&dz~0%hZFe#1<-{Z54TrIJ>0HaM zFLk1Wtr=)EIbaC=F*`iaYH}j!U{4P`tou%22lmvuQJ8Yp)`k;U4r?buvV1f_2^HA6 zJzI4D7TCvo#=*|Ojr+xNcHzcJ?PZUvpa@fQjW=x(wL4MdcjAxsekhu_+{V&_p6W($ zlKueUR)ksre-Ap%aD#=0qtZSZ zE=K;8zGCm2z~@aUx^E{``62i<;@R)#d+#^!dtKY(8k`6-0$v>pv_VT4o+*pDT{t<< z4dkkZUU(2@2G#<{Z{m+)O(l7aGDZ0`DDcN6x54l3d23t&{GUXRoIRn5iAa1D9jfte zj!zB!pK%85U|nZT?1c#zrg_4RwwKID23|)zVneis$wR*B1xe&71J$$Gfu&*%yXAm)8nf$)YuK4w+2ikod(CcE|8Z^Pni2zDVgA~?4SL< zBV9?xIB5^VIY;Mw=k=ZMe9!xzR;!tUwD_Se@O~pj{Q-ZdiOE;!`zHI9A*dDuw}qPQyNMz1nbnr zN~KWoKCFi+xISN&=kFJO1iA9oe4W?TLS>+euj2KuG6Qzr&Ksa~@DAPxrIUB^CMaFJ zi#J1A%~$g*lr?+}Z-LUyyLl^=wR|mK$5*_n8>oX?Jzoj6dZB^0jkHmeXoY<@!hgNG z6Lq(*&|_LpgYO1x{%AlHqar5?A;BjJ94`#UE_Fz9R1`R$C<{YAzbtWqh#ckQVSzh7 zG!zO%1nzJ^<|Ns7DG<5D$)X^1n$4$&0}?kZghmCC^G8Pmf<#8B6JI1VcU|Q8fGGH3 z{xPmC5b=j%JkD{kb1)VN@f`ua>*5x39=0Rwiv)%QN$w0v(a6OuoG-$YDKB*SXhY2931nC7?5Ql!o?yyKwk_CvXA%4zOFLt_`AnW)p`vM`~U`XIp6u>TUo4!y0CWXTxrsbH_Y5o*x$3rWuFA|AD8}v!X zJQ~H=51Ym*6y53AXh`@}191KVW#Sl05C2+b29fbDPNu`%@ zO@a#QR`*gAuTd%KDeBvdY)~O4nIU*-uNrygecS@1ebfa9MU86{nxHA)Ql2NY>O3^{ zhKr$0@N)4_9Zgd(jx8UNudDkzMKx31ltf>FW1XdF`1>mOtJ<^F722aaCgKJajX3Bp zpB`}m`vpD%mG99r)D#sg$72DOr`3I*qCA>o0gAHWy)dHS?ClCi zl8~QSnk2lwHyMv+5WL$X#LbAiac?yyPKvj*hJucZYo8 zLEg6~PKZ7x3*P)A=-eHO`h6j34^;9Hz#A#a2HBq{sM(euwf~@fZY0yxGeOUtLTW?WypN71Hrllya)%4>(F4o%1ISQ2qv{JJ4-dk&H0-yRL28pXhc!j7P)={L}ZR zq4GF9s*hHp#}Ly+9Kw)7;^`Yj#mG;b5_babTLawpE4aKwzu`>K|47f! zz=dlWYK;09E$~PU5aedSgHF5@iViA;jN(1lt1HAMy3)Z0?zg>lH~UV{5(#@4aS{$-(J&PwoEBo}9h<^+TWOpz#YrZm(?W zqUQ`zA2gbx%iosioPp#oUYhz_~5@ETMWfN=i^gv%tDH92xoZ5Y=*27*zqeO&(-h$3N(8xlHElhDI6*!2~h zy)?!Mq_h@^7~{Z-*HU#>q7D50;t9pSSA5tndG%N6H%5>a6a*m^^3dk_(m0fiVkr$rkL}}B<@YI zsK-K?rC3Y9aCReDYhYGfCN;8AUNM6O7z6YpIIGqHSVBz?7)&wcIgOnx{xGjO4Ui}l zrJaDQ2?~_9s@fULbxZQxTvNu@G0~r^v`zHos_O1h^}6awUCvcEbN2e#;B`=mKn z(=ao3eQeg9so6Yf$+;WfHN9n;ZOOPjlh*&Pto=bl&f@&UQI~bJr5$Z^bs5K#lg6Br z%Q{=r&eplMv~z3Lu`BJ^m2o^XX}numbM2+6mli9zC3|zKb7wr}3oY8(}yu39UW zjg+M}#cs^iHND&MR>$|dvUT0*y6(xtxyrg^_;zLcQZ<*WZkuz@ndjRV!VBJ8eW`C; zOr7?noP#h+jU8sGvcoKy&YM2cTiOhq5@odn#G`<~LJpbBbxsvsVW2s3u@F zc{$hstus_f29}=IKI$6d*Ic6f8e)ebLoH{}g_(Go_zN@%=DG&$uyNfuQ<8AJ=2a@8 z+gDVqR5miMFIj$|(5+$L@!Aq=hn*Pwnql0@CSV7KgkjAQMaB*6*!5K~|DbuJm4O+@ z4aGefW#T2mg9VX7SEXQ)ftEVTMj-oGm4fzF4e;|uQ4j=C16q}M{f**&f<<;$0xGiD zwZh%xez9OIV$Fw|YA8Tdwkk zi~+6FNbk%QwPq-$yYYE(0O@iAda8%t;?fko2x(RIo4u(2fVqXf(SBYk z2y)aKq|>k-Xd9?iz}|>8>y3^m^p!GO7Zp@=M+Lx58If0Lv&TWqF)%G+Lqmb9;KzxD z;YSo4y}bA=F0J8VJ@oy^fiP$rk?0l0h}C#B0zYH2A5|E@gddzk0V#^8@yTKz3>CkQ z<8|Q?vSU$s;pe5T@8&p06e4{CFtMte)6>ti4GV4>j;jG4Ya3Kg_ec^WOb zui}GXH`1rjo3#c%EGkvtgDO|1h{DuEu|m{IQk35M?Tol zDxxz5oPxxHU?RS-AnwB!jl!T?M#&pP=$(1UF;iQxJiLW~Ou-p|k97)$zXAW!e#p>0 zQNgHYw4Yj#ScgpAn&E05uZS3W->4Uco_29LdWIr+2r`A1tlUcZY z^SM;R!Hl^t#q>Suyh2t)@OvKKTe+&b7UpE+-z42A`clbF6crh@A43sx(E$@Is(z(@ zxu^v4TD(!7A-V2`{{6x#Ac)uWBl3p7a%N)t^QOOGW;4(7mK)YGRbcHMQ~+G%D!4Ti z*aW4j3}8pwjseRD-YNOPn~5Q!xV59BaOoXz-HH~XV#viwz%PF2WFhkRNURJ5qwEVr zNQe*OVT3**6orw&HXZ_lv5?%uZBnZq$pd~VMjjg#6hom~VK5#Tuj=S13vl2b;%r$% z{K?~ML=^!_4HK@p7@jI`YIZ~;A+T}D&b+c16XLz6MgpV6zeenRFx(L&ANU(1K{p~V zv9qub4k9n*Cxq9e2U{1gCr$$`#Z4U@1@B$F`2@P?xO^{!0t50epsu(Y5mz3gVg#QY zaS|&Pz2YSjIXroQSItoL)1*VhxPaOB2JbwJNVkUDS z6e<`Dl$VJ!0VXPc-r`#@nq*0+NWXw=f?Be=veu1h>&BnfELwL?9LO=|JYUWkYnH6e zYv-rVFWG97`_r}!v;ApXN6vb7*+l8w4=9~Zzigp&tO{Pg8vJP5)}6ENeGGU_uChL7 zJ+W*clQA%vxok2A>D~HJuf?8a8`5k;@^pr6N-<53G7z*b2?LpgyqtlQh<}_mQ6&c+ zJnAAFfw6`3WdQ^A5kb_G4%*Zar83VtzHA0?Ski0Ri_#{!XzL}w$_A+F(!6R7tV%c= zCc=`|fDq|1aA)!cBARpwU9f0J<;P*Js_4>s8C4;YzzwKFM`DQ?1~Ck{Dnan2AjZIF zZIUr&LQBrXdjOo8dB1;H@Q;9D5Z8d25XGGq!-O&P z!TUY~f>&xGjI;dN4P2#Eok87W2hGOPKYD9qI4<520V;I2QhmQG6}I?X2Ypg91{k8 zMEn3ce}YiIAg0?A>zd{>Z2iQ(WropJE!8|R@62}hrn`Hy-TmqA{!I7bOwEx=3&(1JH()ON=efPv+h`nan`r9nTw%o~v*%ub}{ri!hAGrgeFAPh5dw0&^ zyz5+_YTA)@?wB~5W8GPHeVSdr$ZjApvmFc8`Ik}^2mj0eo8Yg5cZ_Gjo&8B=L-InV zvK`#o?oC;DciP=O-;v#VAied#t=ja~lNtA^6ysWQY)ZB4$T*&!II;x3=c5_d)`?>u z85~K=T*Ynoc9=a^v17q9|KiOHsZ+kx$-&g%<&@>hQk8S2?t0yybXt=c-NtoU|&TLzo1YDK~a@tx)aHtIv0=^(BBuy);n-P#X#>!7X}y%1gxM7&-PL!3q{mO^Z$ zGapeANw5gsgDe6B2Q!I3#_V4q1Fu8CFGF+dL(MCoB#K;uI|j}S=w&a~bS7 z!v5RCqwpn^#*06LpRnoI9&ROjIg{_gC%R$_!FLVk;G7o<2PNB!+d*?As$}Oeuoxxy zCJixwBg6&F2-dW)FV8yhd(fbmz22c1gyRISS48eBzKNNPnS>e2d!n<7+pzY}kSTi5 z{(-TIsCS8IQHwL!3^$2_0VX26Yy>+{O(k51u%$c`>cTdROP3fAu}$-w5uZIyg%&R; z&*oEO@F7)r|J8u}UjCyd5`TgZrNU7@783S|H(>y}FeN)mM7<*OIvnK znC|Nhbp3s&hTd@BOln%X=Dx*1ciwk4(Hrj{qMM=n2yLSK=?88%-TYvC3vFFKNmGnv cGJacI`;pms{q&p9XUq*LreRr26+ZO%f1t1ai2wiq diff --git a/ops/offline-kit/__pycache__/test_build_offline_kit.cpython-312.pyc b/ops/offline-kit/__pycache__/test_build_offline_kit.cpython-312.pyc deleted file mode 100644 index bb43b26e3218f82192b63ebb7a44b52f347b5407..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11107 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 bzt*}-x|R=K`Qh~sum9bReJ!Q)5C;Db$5k0l diff --git a/ops/offline-kit/build_offline_kit.py b/ops/offline-kit/build_offline_kit.py index c8e1cbd4e..53b63a423 100644 --- a/ops/offline-kit/build_offline_kit.py +++ b/ops/offline-kit/build_offline_kit.py @@ -205,6 +205,36 @@ def copy_bootstrap_configs(staging_dir: Path) -> None: copy_if_exists(notify_doc, notify_bootstrap_dir / "README.md") +def verify_required_seed_data(repo_root: Path) -> None: + ruby_git_sources = repo_root / "seed-data" / "analyzers" / "ruby" / "git-sources" + if not ruby_git_sources.is_dir(): + raise FileNotFoundError(f"Missing Ruby git-sources seed directory: {ruby_git_sources}") + + required_files = [ + ruby_git_sources / "Gemfile.lock", + ruby_git_sources / "expected.json", + ] + for path in required_files: + if not path.exists(): + raise FileNotFoundError(f"Offline kit seed artefact missing: {path}") + + +def copy_third_party_licenses(staging_dir: Path) -> None: + licenses_src = REPO_ROOT / "third-party-licenses" + if not licenses_src.is_dir(): + return + + target_dir = staging_dir / "third-party-licenses" + target_dir.mkdir(parents=True, exist_ok=True) + + entries = sorted(licenses_src.iterdir(), key=lambda entry: entry.name.lower()) + for entry in entries: + if entry.is_dir(): + shutil.copytree(entry, target_dir / entry.name, dirs_exist_ok=True) + elif entry.is_file(): + shutil.copy2(entry, target_dir / entry.name) + + def package_telemetry_bundle(staging_dir: Path) -> None: script = TELEMETRY_TOOLS_DIR / "package_offline_bundle.py" if not script.exists(): @@ -323,12 +353,13 @@ def sign_blob( return sig_path -def build_offline_kit(args: argparse.Namespace) -> MutableMapping[str, Any]: - release_dir = args.release_dir.resolve() - staging_dir = args.staging_dir.resolve() - output_dir = args.output_dir.resolve() - +def build_offline_kit(args: argparse.Namespace) -> MutableMapping[str, Any]: + release_dir = args.release_dir.resolve() + staging_dir = args.staging_dir.resolve() + output_dir = args.output_dir.resolve() + verify_release(release_dir) + verify_required_seed_data(REPO_ROOT) if not args.skip_smoke: run_rust_analyzer_smoke() run_python_analyzer_smoke() @@ -346,11 +377,12 @@ def build_offline_kit(args: argparse.Namespace) -> MutableMapping[str, Any]: copy_collections(manifest_data, release_dir, staging_dir) copy_plugins_and_assets(staging_dir) copy_bootstrap_configs(staging_dir) + copy_third_party_licenses(staging_dir) package_telemetry_bundle(staging_dir) - - offline_manifest_path, offline_manifest_sha = write_offline_manifest( - staging_dir, - args.version, + + offline_manifest_path, offline_manifest_sha = write_offline_manifest( + staging_dir, + args.version, args.channel, release_manifest_sha, ) diff --git a/seed-data/analyzers/ruby/git-sources/Gemfile b/seed-data/analyzers/ruby/git-sources/Gemfile new file mode 100644 index 000000000..ee0b91cf7 --- /dev/null +++ b/seed-data/analyzers/ruby/git-sources/Gemfile @@ -0,0 +1,12 @@ +source "https://rubygems.org" + +git "https://github.com/example/git-gem.git", branch: "main" do + gem "git-gem" +end + +gem "httparty", "~> 0.21.0" + +path "../vendor/path-gem" do + gem "path-gem", "~> 2.1" +end + diff --git a/seed-data/analyzers/ruby/git-sources/Gemfile.lock b/seed-data/analyzers/ruby/git-sources/Gemfile.lock new file mode 100644 index 000000000..121b9578e --- /dev/null +++ b/seed-data/analyzers/ruby/git-sources/Gemfile.lock @@ -0,0 +1,31 @@ +GIT + remote: https://github.com/example/git-gem.git + revision: 0123456789abcdef0123456789abcdef01234567 + branch: main + specs: + git-gem (0.5.0) + +PATH + remote: vendor/plugins/path-gem + specs: + path-gem (2.1.3) + rake (~> 13.0) + +GEM + remote: https://rubygems.org/ + specs: + httparty (0.21.0) + multi_xml (~> 0.5) + multi_xml (0.6.0) + rake (13.1.0) + +PLATFORMS + ruby + +DEPENDENCIES + git-gem! + httparty (~> 0.21.0) + path-gem (~> 2.1)! + +BUNDLED WITH + 2.5.10 diff --git a/seed-data/analyzers/ruby/git-sources/app/main.rb b/seed-data/analyzers/ruby/git-sources/app/main.rb new file mode 100644 index 000000000..62ba96c5f --- /dev/null +++ b/seed-data/analyzers/ruby/git-sources/app/main.rb @@ -0,0 +1,7 @@ +require "git-gem" +require "path-gem" +require "httparty" + +puts GitGem.version +puts PathGem::Runner.new.perform +puts HTTParty.get("https://example.invalid") diff --git a/seed-data/analyzers/ruby/git-sources/expected.json b/seed-data/analyzers/ruby/git-sources/expected.json new file mode 100644 index 000000000..3be6ae140 --- /dev/null +++ b/seed-data/analyzers/ruby/git-sources/expected.json @@ -0,0 +1,130 @@ +[ + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/git-gem@0.5.0", + "purl": "pkg:gem/git-gem@0.5.0", + "name": "git-gem", + "version": "0.5.0", + "type": "gem", + "usedByEntrypoint": true, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "runtime.entrypoints": "app/main.rb", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/httparty@0.21.0", + "purl": "pkg:gem/httparty@0.21.0", + "name": "httparty", + "version": "0.21.0", + "type": "gem", + "usedByEntrypoint": true, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "runtime.entrypoints": "app/main.rb", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/multi_xml@0.6.0", + "purl": "pkg:gem/multi_xml@0.6.0", + "name": "multi_xml", + "version": "0.6.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/path-gem@2.1.3", + "purl": "pkg:gem/path-gem@2.1.3", + "name": "path-gem", + "version": "2.1.3", + "type": "gem", + "usedByEntrypoint": true, + "metadata": { + "artifact": "vendor/cache/path-gem-2.1.3.gem", + "capability.net": "true", + "declaredOnly": "false", + "groups": "default", + "lockfile": "Gemfile.lock", + "runtime.entrypoints": "app/main.rb", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "vendor-cache" + }, + "evidence": [ + { + "kind": "file", + "source": "path-gem-2.1.3.gem", + "locator": "vendor/cache/path-gem-2.1.3.gem" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/rake@13.1.0", + "purl": "pkg:gem/rake@13.1.0", + "name": "rake", + "version": "13.1.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + } +] diff --git a/seed-data/analyzers/ruby/git-sources/vendor/cache/path-gem-2.1.3.gem b/seed-data/analyzers/ruby/git-sources/vendor/cache/path-gem-2.1.3.gem new file mode 100644 index 000000000..e69de29bb diff --git a/seed-data/analyzers/ruby/git-sources/vendor/plugins/path-gem/.keep b/seed-data/analyzers/ruby/git-sources/vendor/plugins/path-gem/.keep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/seed-data/analyzers/ruby/git-sources/vendor/plugins/path-gem/.keep @@ -0,0 +1 @@ + diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index 4cb6a798e..5c089e2e3 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -7098,6 +7098,7 @@ internal static class CommandHandlers var source = snapshots ?? Array.Empty(); var entries = source + .Where(static snapshot => string.Equals(snapshot.Type, "gem", StringComparison.OrdinalIgnoreCase)) .Select(RubyInspectEntry.FromSnapshot) .OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase) diff --git a/src/Policy/StellaOps.Policy.Engine/Compilation/PolicyParser.cs b/src/Policy/StellaOps.Policy.Engine/Compilation/PolicyParser.cs index a6ebf82f8..19d63a5ef 100644 --- a/src/Policy/StellaOps.Policy.Engine/Compilation/PolicyParser.cs +++ b/src/Policy/StellaOps.Policy.Engine/Compilation/PolicyParser.cs @@ -574,12 +574,12 @@ internal sealed class PolicyParser PolicyExpression expr = new PolicyIdentifierExpression(identifier.Text, identifier.Span); while (true) { - if (Match(TokenKind.Dot)) - { - var member = Consume(TokenKind.Identifier, "Expected identifier after '.'.", "expression.member"); - expr = new PolicyMemberAccessExpression(expr, member.Text, new SourceSpan(expr.Span.Start, member.Span.End)); - continue; - } + if (Match(TokenKind.Dot)) + { + var member = ConsumeIdentifier("Expected identifier after '.'.", "expression.member"); + expr = new PolicyMemberAccessExpression(expr, member.Text, new SourceSpan(expr.Span.Start, member.Span.End)); + continue; + } if (Match(TokenKind.LeftParen)) { @@ -609,12 +609,26 @@ internal sealed class PolicyParser break; } - return expr; - } - - private bool Match(TokenKind kind) - { - if (Check(kind)) + return expr; + } + + private DslToken ConsumeIdentifier(string message, string path) + { + if (Check(TokenKind.Identifier) || IsKeywordIdentifier(Current.Kind)) + { + return Advance(); + } + + diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnexpectedToken, message, path)); + return Advance(); + } + + private static bool IsKeywordIdentifier(TokenKind kind) => + kind == TokenKind.KeywordSource; + + private bool Match(TokenKind kind) + { + if (Check(kind)) { Advance(); return true; diff --git a/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyEvaluationContext.cs b/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyEvaluationContext.cs index b17995f72..3cd50b335 100644 --- a/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyEvaluationContext.cs +++ b/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyEvaluationContext.cs @@ -11,13 +11,13 @@ internal sealed record PolicyEvaluationRequest( PolicyIrDocument Document, PolicyEvaluationContext Context); -internal sealed record PolicyEvaluationContext( - PolicyEvaluationSeverity Severity, - PolicyEvaluationEnvironment Environment, - PolicyEvaluationAdvisory Advisory, - PolicyEvaluationVexEvidence Vex, - PolicyEvaluationSbom Sbom, - PolicyEvaluationExceptions Exceptions); +internal sealed record PolicyEvaluationContext( + PolicyEvaluationSeverity Severity, + PolicyEvaluationEnvironment Environment, + PolicyEvaluationAdvisory Advisory, + PolicyEvaluationVexEvidence Vex, + PolicyEvaluationSbom Sbom, + PolicyEvaluationExceptions Exceptions); internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null); @@ -43,10 +43,28 @@ internal sealed record PolicyEvaluationVexStatement( string StatementId, DateTimeOffset? Timestamp = null); -internal sealed record PolicyEvaluationSbom(ImmutableHashSet Tags) -{ - public bool HasTag(string tag) => Tags.Contains(tag); -} +internal sealed record PolicyEvaluationSbom( + ImmutableHashSet Tags, + ImmutableArray Components) +{ + public PolicyEvaluationSbom(ImmutableHashSet Tags) + : this(Tags, ImmutableArray.Empty) + { + } + + public static readonly PolicyEvaluationSbom Empty = new( + ImmutableHashSet.Empty.WithComparer(StringComparer.OrdinalIgnoreCase), + ImmutableArray.Empty); + + public bool HasTag(string tag) => Tags.Contains(tag); +} + +internal sealed record PolicyEvaluationComponent( + string Name, + string Version, + string Type, + string? Purl, + ImmutableDictionary Metadata); internal sealed record PolicyEvaluationResult( bool Matched, diff --git a/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs b/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs index 655862b48..c78eb1e06 100644 --- a/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs +++ b/src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyExpressionEvaluator.cs @@ -98,10 +98,20 @@ internal sealed class PolicyExpressionEvaluator return sbom.Get(member.Member); } - if (raw is ImmutableDictionary dict && dict.TryGetValue(member.Member, out var value)) - { - return new EvaluationValue(value); - } + if (raw is ComponentScope componentScope) + { + return componentScope.Get(member.Member); + } + + if (raw is RubyComponentScope rubyScope) + { + return rubyScope.Get(member.Member); + } + + if (raw is ImmutableDictionary dict && dict.TryGetValue(member.Member, out var value)) + { + return new EvaluationValue(value); + } if (raw is PolicyEvaluationVexStatement stmt) { @@ -129,47 +139,51 @@ internal sealed class PolicyExpressionEvaluator } } - if (invocation.Target is PolicyMemberAccessExpression member && member.Target is PolicyIdentifierExpression root) - { - if (root.Name == "vex") - { - var vex = Evaluate(member.Target, scope); - if (vex.Raw is VexScope vexScope) - { - return member.Member switch - { - "any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)), - "latest" => new EvaluationValue(vexScope.Latest()), - _ => EvaluationValue.Null, - }; - } - } - - if (root.Name == "sbom") - { - var sbom = Evaluate(member.Target, scope); - if (sbom.Raw is SbomScope sbomScope) - { - return member.Member switch - { - "has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this), - _ => EvaluationValue.Null, - }; - } - } - - if (root.Name == "advisory") - { - var advisory = Evaluate(member.Target, scope); - if (advisory.Raw is AdvisoryScope advisoryScope) - { - return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this); - } - } - } - - return EvaluationValue.Null; - } + if (invocation.Target is PolicyMemberAccessExpression member) + { + var targetValue = Evaluate(member.Target, scope); + var targetRaw = targetValue.Raw; + if (targetRaw is RubyComponentScope rubyScope) + { + return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this); + } + + if (targetRaw is ComponentScope componentScope) + { + return componentScope.Invoke(member.Member, invocation.Arguments, scope, this); + } + + if (member.Target is PolicyIdentifierExpression root) + { + if (root.Name == "vex" && targetRaw is VexScope vexScope) + { + return member.Member switch + { + "any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)), + "latest" => new EvaluationValue(vexScope.Latest()), + _ => EvaluationValue.Null, + }; + } + + if (root.Name == "sbom" && targetRaw is SbomScope sbomScope) + { + return member.Member switch + { + "has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this), + "any_component" => sbomScope.AnyComponent(invocation.Arguments, scope, this), + _ => EvaluationValue.Null, + }; + } + + if (root.Name == "advisory" && targetRaw is AdvisoryScope advisoryScope) + { + return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this); + } + } + } + + return EvaluationValue.Null; + } private EvaluationValue EvaluateIndexer(PolicyIndexerExpression indexer, EvaluationScope scope) { @@ -428,31 +442,322 @@ internal sealed class PolicyExpressionEvaluator this.sbom = sbom; } - public EvaluationValue Get(string member) - { - if (member.Equals("tags", StringComparison.OrdinalIgnoreCase)) - { - return new EvaluationValue(sbom.Tags.ToImmutableArray()); - } - - return EvaluationValue.Null; - } - - public EvaluationValue HasTag(ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) - { - var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; - if (string.IsNullOrWhiteSpace(tag)) - { - return EvaluationValue.False; - } - - return new EvaluationValue(sbom.HasTag(tag!)); - } - } - - private sealed class VexScope - { - private readonly PolicyExpressionEvaluator evaluator; + public EvaluationValue Get(string member) + { + if (member.Equals("tags", StringComparison.OrdinalIgnoreCase)) + { + return new EvaluationValue(sbom.Tags.ToImmutableArray()); + } + + if (member.Equals("components", StringComparison.OrdinalIgnoreCase)) + { + return new EvaluationValue(sbom.Components + .Select(component => (object?)new ComponentScope(component)) + .ToImmutableArray()); + } + + return EvaluationValue.Null; + } + + public EvaluationValue HasTag(ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) + { + var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + if (string.IsNullOrWhiteSpace(tag)) + { + return EvaluationValue.False; + } + + return new EvaluationValue(sbom.HasTag(tag!)); + } + + public EvaluationValue AnyComponent(ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) + { + if (arguments.Length == 0 || sbom.Components.IsDefaultOrEmpty) + { + return EvaluationValue.False; + } + + var predicate = arguments[0]; + foreach (var component in sbom.Components) + { + var locals = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["component"] = new ComponentScope(component), + }; + + if (component.Type.Equals("gem", StringComparison.OrdinalIgnoreCase)) + { + locals["ruby"] = new RubyComponentScope(component); + } + + var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals); + if (evaluator.EvaluateBoolean(predicate, nestedScope)) + { + return EvaluationValue.True; + } + } + + return EvaluationValue.False; + } + } + + private sealed class ComponentScope + { + private readonly PolicyEvaluationComponent component; + + public ComponentScope(PolicyEvaluationComponent component) + { + this.component = component; + } + + public EvaluationValue Get(string member) + { + return member.ToLowerInvariant() switch + { + "name" => new EvaluationValue(component.Name), + "version" => new EvaluationValue(component.Version), + "type" => new EvaluationValue(component.Type), + "purl" => new EvaluationValue(component.Purl), + "metadata" => new EvaluationValue(component.Metadata), + _ => component.Metadata.TryGetValue(member, out var value) + ? new EvaluationValue(value) + : EvaluationValue.Null, + }; + } + + public EvaluationValue Invoke(string member, ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) + { + if (member.Equals("has_metadata", StringComparison.OrdinalIgnoreCase)) + { + var key = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + if (string.IsNullOrWhiteSpace(key)) + { + return EvaluationValue.False; + } + + return new EvaluationValue(component.Metadata.ContainsKey(key!)); + } + + return EvaluationValue.Null; + } + } + + private sealed class RubyComponentScope + { + private readonly PolicyEvaluationComponent component; + private readonly ImmutableHashSet groups; + + public RubyComponentScope(PolicyEvaluationComponent component) + { + this.component = component; + groups = ParseGroups(component.Metadata); + } + + public EvaluationValue Get(string member) + { + return member.ToLowerInvariant() switch + { + "groups" => new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray()), + "declaredonly" => new EvaluationValue(IsDeclaredOnly()), + "source" => new EvaluationValue(GetSource() ?? string.Empty), + _ => component.Metadata.TryGetValue(member, out var value) + ? new EvaluationValue(value) + : EvaluationValue.Null, + }; + } + + public EvaluationValue Invoke(string member, ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) + { + switch (member.ToLowerInvariant()) + { + case "group": + { + var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + return new EvaluationValue(name is not null && groups.Contains(name)); + } + case "groups": + return new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray()); + case "declared_only": + return new EvaluationValue(IsDeclaredOnly()); + case "source": + { + if (arguments.Length == 0) + { + return new EvaluationValue(GetSource() ?? string.Empty); + } + + var requested = evaluator.Evaluate(arguments[0], scope).AsString(); + if (string.IsNullOrWhiteSpace(requested)) + { + return EvaluationValue.False; + } + + var kind = GetSourceKind(); + return new EvaluationValue(string.Equals(kind, requested, StringComparison.OrdinalIgnoreCase)); + } + case "capability": + { + var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null; + return new EvaluationValue(HasCapability(name)); + } + case "capability_any": + { + var capabilities = EvaluateAsStringSet(arguments, scope, evaluator); + return new EvaluationValue(capabilities.Any(HasCapability)); + } + default: + return EvaluationValue.Null; + } + } + + private bool HasCapability(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + var normalized = name.Trim(); + if (normalized.Length == 0) + { + return false; + } + + if (component.Metadata.TryGetValue($"capability.{normalized}", out var value)) + { + return IsTruthy(value); + } + + if (normalized.StartsWith("scheduler.", StringComparison.OrdinalIgnoreCase)) + { + var group = normalized.Substring("scheduler.".Length); + var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue) + ? listValue + : null; + return ContainsDelimitedValue(schedulerList, group); + } + + if (normalized.Equals("scheduler", StringComparison.OrdinalIgnoreCase)) + { + var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue) + ? listValue + : null; + return !string.IsNullOrWhiteSpace(schedulerList); + } + + return false; + } + + private bool IsDeclaredOnly() + { + return component.Metadata.TryGetValue("declaredOnly", out var value) && IsTruthy(value); + } + + private string? GetSource() + { + return component.Metadata.TryGetValue("source", out var value) ? value : null; + } + + private string? GetSourceKind() + { + var source = GetSource(); + if (string.IsNullOrWhiteSpace(source)) + { + return null; + } + + source = source.Trim(); + if (source.StartsWith("git:", StringComparison.OrdinalIgnoreCase)) + { + return "git"; + } + + if (source.StartsWith("path:", StringComparison.OrdinalIgnoreCase)) + { + return "path"; + } + + if (source.StartsWith("vendor-cache", StringComparison.OrdinalIgnoreCase)) + { + return "vendor-cache"; + } + + if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || source.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return "registry"; + } + + return source; + } + + private static ImmutableHashSet ParseGroups(ImmutableDictionary metadata) + { + if (!metadata.TryGetValue("groups", out var value) || string.IsNullOrWhiteSpace(value)) + { + return ImmutableHashSet.Empty; + } + + var groups = value + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static g => !string.IsNullOrWhiteSpace(g)) + .Select(static g => g.Trim()) + .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + + return groups; + } + + private static bool ContainsDelimitedValue(string? delimited, string value) + { + if (string.IsNullOrWhiteSpace(delimited) || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return delimited + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(entry => entry.Equals(value, StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsTruthy(string? value) + { + return value is not null + && (value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase)); + } + + private static ImmutableHashSet EvaluateAsStringSet(ImmutableArray arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator) + { + var builder = ImmutableHashSet.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var argument in arguments) + { + var evaluated = evaluator.Evaluate(argument, scope).Raw; + switch (evaluated) + { + case ImmutableArray array: + foreach (var item in array) + { + if (item is string text && !string.IsNullOrWhiteSpace(text)) + { + builder.Add(text.Trim()); + } + } + + break; + case string text when !string.IsNullOrWhiteSpace(text): + builder.Add(text.Trim()); + break; + } + } + + return builder.ToImmutable(); + } + } + + private sealed class VexScope + { + private readonly PolicyExpressionEvaluator evaluator; private readonly PolicyEvaluationVexEvidence vex; public VexScope(PolicyExpressionEvaluator evaluator, PolicyEvaluationVexEvidence vex) diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs index c5fe0b663..9b86fe994 100644 --- a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PolicyEvaluatorTests.cs @@ -51,14 +51,26 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" { because "Respect strong vendor VEX claims." } - rule alert_warn_eol_runtime priority 1 { - when severity.normalized <= "Medium" - and sbom.has_tag("runtime:eol") - then warn message "Runtime marked as EOL; upgrade recommended." - because "Deprecated runtime should be upgraded." - } -} -"""; + rule alert_warn_eol_runtime priority 1 { + when severity.normalized <= "Medium" + and sbom.has_tag("runtime:eol") + then warn message "Runtime marked as EOL; upgrade recommended." + because "Deprecated runtime should be upgraded." + } + + rule block_ruby_dev priority 4 { + when sbom.any_component(ruby.group("development") and ruby.declared_only()) + then status := "blocked" + because "Development-only Ruby gems without install evidence cannot ship." + } + + rule warn_ruby_git_sources { + when sbom.any_component(ruby.source("git")) + then warn message "Git-sourced Ruby gem present; review required." + because "Git-sourced Ruby dependencies require explicit review." + } +} +"""; private readonly PolicyCompiler compiler = new(); private readonly PolicyEvaluationService evaluationService = new(); @@ -113,11 +125,11 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" { public void Evaluate_WarnRuleEmitsWarning() { var document = CompileBaseline(); - var tags = ImmutableHashSet.Create("runtime:eol"); - var context = CreateContext("Medium", "internal") with - { - Sbom = new PolicyEvaluationSbom(tags) - }; + var tags = ImmutableHashSet.Create("runtime:eol"); + var context = CreateContext("Medium", "internal") with + { + Sbom = new PolicyEvaluationSbom(tags) + }; var result = evaluationService.Evaluate(document, context); @@ -261,16 +273,74 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" { Assert.NotNull(result.AppliedException); Assert.Equal("exc-rule", result.AppliedException!.ExceptionId); Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]); - Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]); - Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]); - } - - private PolicyIrDocument CompileBaseline() - { - var compilation = compiler.Compile(BaselinePolicy); - Assert.True(compilation.Success, Describe(compilation.Diagnostics)); - return Assert.IsType(compilation.Document); - } + Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]); + Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]); + } + + [Fact] + public void Evaluate_RubyDevComponentBlocked() + { + var document = CompileBaseline(); + var component = CreateRubyComponent( + name: "dev-only", + version: "1.0.0", + groups: "development;test", + declaredOnly: true, + source: "https://rubygems.org/", + capabilities: new[] { "exec" }); + + var context = CreateContext("Medium", "internal") with + { + Sbom = new PolicyEvaluationSbom( + ImmutableHashSet.Empty.WithComparer(StringComparer.OrdinalIgnoreCase), + ImmutableArray.Create(component)) + }; + + var result = evaluationService.Evaluate(document, context); + + Assert.True(result.Matched); + Assert.Equal("block_ruby_dev", result.RuleName); + Assert.Equal("blocked", result.Status); + } + + [Fact] + public void Evaluate_RubyGitComponentWarns() + { + var document = CompileBaseline(); + var component = CreateRubyComponent( + name: "git-gem", + version: "0.5.0", + groups: "default", + declaredOnly: false, + source: "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567", + capabilities: Array.Empty(), + schedulerCapabilities: new[] { "sidekiq" }); + + var context = CreateContext("Low", "internal") with + { + Sbom = new PolicyEvaluationSbom( + ImmutableHashSet.Empty.WithComparer(StringComparer.OrdinalIgnoreCase), + ImmutableArray.Create(component)) + }; + + var result = evaluationService.Evaluate(document, context); + + Assert.True(result.Matched); + Assert.Equal("warn_ruby_git_sources", result.RuleName); + Assert.Equal("warned", result.Status); + Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase)); + } + + private PolicyIrDocument CompileBaseline() + { + var compilation = compiler.Compile(BaselinePolicy); + if (!compilation.Success) + { + Console.WriteLine(Describe(compilation.Diagnostics)); + } + Assert.True(compilation.Success, Describe(compilation.Diagnostics)); + return Assert.IsType(compilation.Document); + } private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null) { @@ -282,10 +352,67 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" { }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)), new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary.Empty), PolicyEvaluationVexEvidence.Empty, - new PolicyEvaluationSbom(ImmutableHashSet.Empty), - exceptions ?? PolicyEvaluationExceptions.Empty); - } + PolicyEvaluationSbom.Empty, + exceptions ?? PolicyEvaluationExceptions.Empty); + } - private static string Describe(ImmutableArray issues) => - string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}")); -} + private static string Describe(ImmutableArray issues) => + string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}")); + + private static PolicyEvaluationComponent CreateRubyComponent( + string name, + string version, + string groups, + bool declaredOnly, + string source, + IEnumerable? capabilities = null, + IEnumerable? schedulerCapabilities = null) + { + var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(groups)) + { + metadataBuilder["groups"] = groups; + } + + metadataBuilder["declaredOnly"] = declaredOnly ? "true" : "false"; + + if (!string.IsNullOrWhiteSpace(source)) + { + metadataBuilder["source"] = source.Trim(); + } + + if (capabilities is not null) + { + foreach (var capability in capabilities) + { + if (!string.IsNullOrWhiteSpace(capability)) + { + metadataBuilder[$"capability.{capability.Trim()}"] = "true"; + } + } + } + + if (schedulerCapabilities is not null) + { + var schedulerList = string.Join( + ';', + schedulerCapabilities + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim())); + + if (!string.IsNullOrWhiteSpace(schedulerList)) + { + metadataBuilder["capability.scheduler"] = schedulerList; + } + } + + metadataBuilder["lockfile"] = "Gemfile.lock"; + + return new PolicyEvaluationComponent( + name, + version, + "gem", + $"pkg:gem/{name}@{version}", + metadataBuilder.ToImmutable()); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs new file mode 100644 index 000000000..819dc3bfe --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs @@ -0,0 +1,83 @@ +using System.Collections.Immutable; + +namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations; + +internal static class RubyObservationBuilder +{ + public static RubyObservationDocument Build( + IReadOnlyList packages, + RubyRuntimeGraph runtimeGraph, + RubyCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(packages); + ArgumentNullException.ThrowIfNull(runtimeGraph); + ArgumentNullException.ThrowIfNull(capabilities); + + var packageItems = packages + .OrderBy(static package => package.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static package => package.Version, StringComparer.OrdinalIgnoreCase) + .Select(CreatePackage) + .ToImmutableArray(); + + var runtimeItems = packages + .Select(package => CreateRuntimeEdge(package, runtimeGraph)) + .Where(static edge => edge is not null) + .Select(static edge => edge!) + .OrderBy(static edge => edge.Package, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + var capabilitySummary = new RubyObservationCapabilitySummary( + capabilities.UsesExec, + capabilities.UsesNetwork, + capabilities.UsesSerialization, + capabilities.JobSchedulers + .OrderBy(static scheduler => scheduler, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray()); + + return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary); + } + + private static RubyObservationPackage CreatePackage(RubyPackage package) + { + var groups = package.Groups + .OrderBy(static group => group, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + return new RubyObservationPackage( + package.Name, + package.Version, + package.Source, + package.Platform, + package.DeclaredOnly, + package.LockfileLocator, + package.ArtifactLocator, + groups); + } + + private static RubyObservationRuntimeEdge? CreateRuntimeEdge(RubyPackage package, RubyRuntimeGraph runtimeGraph) + { + if (!runtimeGraph.TryGetUsage(package, out var usage) || usage is null || !usage.HasFiles) + { + return null; + } + + var files = usage.ReferencingFiles + .OrderBy(static file => file, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + var entrypoints = usage.Entrypoints + .OrderBy(static file => file, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + var reasons = usage.Reasons + .OrderBy(static reason => reason, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + return new RubyObservationRuntimeEdge( + package.Name, + usage.UsedByEntrypoint, + files, + entrypoints, + reasons); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs new file mode 100644 index 000000000..379657b2f --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs @@ -0,0 +1,31 @@ +using System.Collections.Immutable; + +namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations; + +internal sealed record RubyObservationDocument( + ImmutableArray Packages, + ImmutableArray RuntimeEdges, + RubyObservationCapabilitySummary Capabilities); + +internal sealed record RubyObservationPackage( + string Name, + string Version, + string Source, + string? Platform, + bool DeclaredOnly, + string? Lockfile, + string? Artifact, + ImmutableArray Groups); + +internal sealed record RubyObservationRuntimeEdge( + string Package, + bool UsedByEntrypoint, + ImmutableArray Files, + ImmutableArray Entrypoints, + ImmutableArray Reasons); + +internal sealed record RubyObservationCapabilitySummary( + bool UsesExec, + bool UsesNetwork, + bool UsesSerialization, + ImmutableArray JobSchedulers); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs new file mode 100644 index 000000000..6d7ee481c --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs @@ -0,0 +1,114 @@ +using System.Buffers; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations; + +internal static class RubyObservationSerializer +{ + public static string Serialize(RubyObservationDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false })) + { + writer.WriteStartObject(); + + WritePackages(writer, document.Packages); + WriteRuntimeEdges(writer, document.RuntimeEdges); + WriteCapabilities(writer, document.Capabilities); + + writer.WriteEndObject(); + writer.Flush(); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + public static string ComputeSha256(string value) + { + ArgumentNullException.ThrowIfNull(value); + var bytes = Encoding.UTF8.GetBytes(value); + var hash = SHA256.HashData(bytes); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static void WritePackages(Utf8JsonWriter writer, ImmutableArray packages) + { + writer.WritePropertyName("packages"); + writer.WriteStartArray(); + foreach (var package in packages) + { + writer.WriteStartObject(); + writer.WriteString("name", package.Name); + writer.WriteString("version", package.Version); + writer.WriteString("source", package.Source); + + if (!string.IsNullOrWhiteSpace(package.Platform)) + { + writer.WriteString("platform", package.Platform); + } + + writer.WriteBoolean("declaredOnly", package.DeclaredOnly); + + if (!string.IsNullOrWhiteSpace(package.Lockfile)) + { + writer.WriteString("lockfile", package.Lockfile); + } + + if (!string.IsNullOrWhiteSpace(package.Artifact)) + { + writer.WriteString("artifact", package.Artifact); + } + + WriteStringArray(writer, "groups", package.Groups); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + private static void WriteRuntimeEdges(Utf8JsonWriter writer, ImmutableArray runtimeEdges) + { + writer.WritePropertyName("runtimeEdges"); + writer.WriteStartArray(); + foreach (var edge in runtimeEdges) + { + writer.WriteStartObject(); + writer.WriteString("package", edge.Package); + writer.WriteBoolean("usedByEntrypoint", edge.UsedByEntrypoint); + WriteStringArray(writer, "files", edge.Files); + WriteStringArray(writer, "entrypoints", edge.Entrypoints); + WriteStringArray(writer, "reasons", edge.Reasons); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + private static void WriteCapabilities(Utf8JsonWriter writer, RubyObservationCapabilitySummary summary) + { + writer.WritePropertyName("capabilities"); + writer.WriteStartObject(); + writer.WriteBoolean("usesExec", summary.UsesExec); + writer.WriteBoolean("usesNetwork", summary.UsesNetwork); + writer.WriteBoolean("usesSerialization", summary.UsesSerialization); + WriteStringArray(writer, "jobSchedulers", summary.JobSchedulers); + writer.WriteEndObject(); + } + + private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray values) + { + writer.WritePropertyName(propertyName); + writer.WriteStartArray(); + foreach (var value in values) + { + writer.WriteStringValue(value); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyBundlerConfig.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyBundlerConfig.cs index 3123d1511..dc656e5f2 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyBundlerConfig.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyBundlerConfig.cs @@ -21,7 +21,7 @@ internal sealed class RubyBundlerConfig return Empty; } - var configPath = Path.Combine(rootPath, \".bundle\", \"config\"); + var configPath = Path.Combine(rootPath, ".bundle", "config"); if (!File.Exists(configPath)) { return Empty; @@ -35,7 +35,9 @@ internal sealed class RubyBundlerConfig foreach (var rawLine in File.ReadAllLines(configPath)) { var line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith(\"#\", StringComparison.Ordinal) || line.StartsWith(\"---\", StringComparison.Ordinal)) + if (line.Length == 0 + || line.StartsWith("#", StringComparison.Ordinal) + || line.StartsWith("---", StringComparison.Ordinal)) { continue; } @@ -53,13 +55,13 @@ internal sealed class RubyBundlerConfig continue; } - value = value.Trim('\"', '\''); + value = value.Trim('"', '\''); - if (key.Equals(\"BUNDLE_GEMFILE\", StringComparison.OrdinalIgnoreCase)) + if (key.Equals("BUNDLE_GEMFILE", StringComparison.OrdinalIgnoreCase)) { AddPath(gemfiles, rootPath, value); } - else if (key.Equals(\"BUNDLE_PATH\", StringComparison.OrdinalIgnoreCase)) + else if (key.Equals("BUNDLE_PATH", StringComparison.OrdinalIgnoreCase)) { AddPath(bundlePaths, rootPath, value); } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs index aa479ddb2..241e24253 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs @@ -103,19 +103,19 @@ internal static class RubyLockParser if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase)) { - currentRevision = line[10..].Trim(); + currentRevision = ExtractValue(line); return; } if (line.StartsWith(" ref:", StringComparison.OrdinalIgnoreCase) && currentRevision is null) { - currentRevision = line[6..].Trim(); + currentRevision = ExtractValue(line); return; } if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase)) { - currentPath = line[6..].Trim(); + currentPath = ExtractValue(line); return; } @@ -200,6 +200,17 @@ internal static class RubyLockParser _ => "rubygems", }; } + + private static string ExtractValue(string line) + { + var separatorIndex = line.IndexOf(':'); + if (separatorIndex < 0 || separatorIndex + 1 >= line.Length) + { + return line.Trim(); + } + + return line[(separatorIndex + 1)..].Trim(); + } } internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyPackageCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyPackageCollector.cs index e83497062..f897c1eb9 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyPackageCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyPackageCollector.cs @@ -112,7 +112,7 @@ internal sealed class RubyPackageBuilder .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) .ToArray(); - var source = _lockSource ?? _artifactSource ?? "unknown"; + var source = _artifactSource ?? _lockSource ?? "unknown"; var evidenceSource = _hasVendor ? _artifactEvidenceSource ?? "vendor" : _lockEvidenceSource ?? "Gemfile.lock"; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs index 40f099d02..93c9e01aa 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs @@ -219,12 +219,37 @@ internal static class RubyVendorArtifactCollector { var normalized = relativePath.Replace('\\', '/'); var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) + if (segments.Length >= 2) { - return normalized; + if (MatchesPrefix(segments, "vendor", "cache")) + { + return "vendor-cache"; + } + + if (MatchesPrefix(segments, "vendor", "bundle")) + { + return "vendor-bundle"; + } + + if (MatchesPrefix(segments, ".bundle", "cache")) + { + return "bundle-cache"; + } } - return segments[0]; + if (segments.Length > 0) + { + return segments[0]; + } + + return normalized; + } + + private static bool MatchesPrefix(IReadOnlyList segments, string first, string second) + { + return segments.Count >= 2 + && segments[0].Equals(first, StringComparison.OrdinalIgnoreCase) + && segments[1].Equals(second, StringComparison.OrdinalIgnoreCase); } private static string EnsureTrailingSeparator(string path) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs index 892d4cfb4..8a29602c9 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs @@ -1,4 +1,8 @@ +using System.Globalization; +using System.Text; using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal; +using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations; +using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Surface.Env; using StellaOps.Scanner.Surface.Validation; @@ -43,6 +47,11 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer evidence: package.CreateEvidence(), usedByEntrypoint: runtimeUsage?.UsedByEntrypoint ?? false); } + + if (packages.Count > 0) + { + EmitObservation(context, writer, packages, runtimeGraph, capabilities); + } } private static async ValueTask EnsureSurfaceValidationAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken) @@ -60,16 +69,120 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["analyzerId"] = \"ruby\", - [\"rootPath\"] = context.RootPath + ["analyzerId"] = "ruby", + ["rootPath"] = context.RootPath }; var validationContext = SurfaceValidationContext.Create( context.Services, - \"StellaOps.Scanner.Analyzers.Lang.Ruby\", + "StellaOps.Scanner.Analyzers.Lang.Ruby", environment.Settings, properties); await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false); } + + private void EmitObservation( + LanguageAnalyzerContext context, + LanguageComponentWriter writer, + IReadOnlyList packages, + RubyRuntimeGraph runtimeGraph, + RubyCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(packages); + ArgumentNullException.ThrowIfNull(runtimeGraph); + ArgumentNullException.ThrowIfNull(capabilities); + + var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities); + var observationJson = RubyObservationSerializer.Serialize(observationDocument); + var observationHash = RubyObservationSerializer.ComputeSha256(observationJson); + var observationBytes = Encoding.UTF8.GetBytes(observationJson); + + var observationMetadata = BuildObservationMetadata( + packages.Count, + observationDocument.RuntimeEdges.Length, + observationDocument.Capabilities); + + TryPersistObservation(Id, context, observationBytes, observationMetadata); + + var observationEvidence = new[] + { + new LanguageComponentEvidence( + LanguageEvidenceKind.Derived, + "ruby.observation", + "document", + observationJson, + observationHash) + }; + + writer.AddFromExplicitKey( + analyzerId: Id, + componentKey: "observation::ruby", + purl: null, + name: "Ruby Observation Summary", + version: null, + type: "ruby-observation", + metadata: observationMetadata, + evidence: observationEvidence); + } + + private static IEnumerable> BuildObservationMetadata( + int packageCount, + int runtimeEdgeCount, + RubyObservationCapabilitySummary capabilities) + { + yield return new KeyValuePair("ruby.observation.packages", packageCount.ToString(CultureInfo.InvariantCulture)); + yield return new KeyValuePair("ruby.observation.runtime_edges", runtimeEdgeCount.ToString(CultureInfo.InvariantCulture)); + yield return new KeyValuePair("ruby.observation.capability.exec", capabilities.UsesExec ? "true" : "false"); + yield return new KeyValuePair("ruby.observation.capability.net", capabilities.UsesNetwork ? "true" : "false"); + yield return new KeyValuePair("ruby.observation.capability.serialization", capabilities.UsesSerialization ? "true" : "false"); + yield return new KeyValuePair("ruby.observation.capability.schedulers", capabilities.JobSchedulers.Length.ToString(CultureInfo.InvariantCulture)); + } + + private static void TryPersistObservation( + string analyzerId, + LanguageAnalyzerContext context, + byte[] observationBytes, + IEnumerable> metadata) + { + if (string.IsNullOrWhiteSpace(analyzerId)) + { + throw new ArgumentException("Analyzer id is required", nameof(analyzerId)); + } + + if (context.AnalysisStore is not { } analysisStore) + { + return; + } + + var metadataDictionary = CreateMetadata(metadata); + var payload = new AnalyzerObservationPayload( + analyzerId: analyzerId, + kind: "ruby.observation", + mediaType: "application/json", + content: observationBytes, + metadata: metadataDictionary, + view: "observations"); + + analysisStore.Set(ScanAnalysisKeys.RubyObservationPayload, payload); + } + + private static IReadOnlyDictionary? CreateMetadata(IEnumerable> metadata) + { + Dictionary? dictionary = null; + foreach (var pair in metadata ?? Array.Empty>()) + { + if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value)) + { + continue; + } + + dictionary ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + dictionary[pair.Key] = pair.Value; + } + + return dictionary; + } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj index 227004fa8..8cc6a515a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj @@ -16,5 +16,6 @@ + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md index ec9055185..604b2342f 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md @@ -2,6 +2,6 @@ | Task ID | State | Notes | | --- | --- | --- | -| `SCANNER-ENG-0016` | DOING (2025-11-10) | Building RubyLockCollector + multi-source vendor ingestion per design §4.1–4.3 (Codex agent). | +| `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector merged with vendor cache ingestion; workspace overrides, bundler groups, git/path fixture, and offline-kit mirror updated. | | `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. | | `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. | diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs index 7c53cee08..764229e6a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs @@ -21,4 +21,6 @@ public static class ScanAnalysisKeys public const string RegistryCredentials = "analysis.registry.credentials"; public const string DenoObservationPayload = "analysis.lang.deno.observation"; + + public const string RubyObservationPayload = "analysis.lang.ruby.observation"; } diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/basic/expected.json b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/basic/expected.json index e312b0218..27b5e8265 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/basic/expected.json +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/basic/expected.json @@ -1,4 +1,28 @@ [ + { + "analyzerId": "ruby", + "componentKey": "observation::ruby", + "name": "Ruby Observation Summary", + "type": "ruby-observation", + "usedByEntrypoint": false, + "metadata": { + "ruby.observation.capability.exec": "true", + "ruby.observation.capability.net": "true", + "ruby.observation.capability.schedulers": "4", + "ruby.observation.capability.serialization": "true", + "ruby.observation.packages": "3", + "ruby.observation.runtime_edges": "3" + }, + "evidence": [ + { + "kind": "derived", + "source": "ruby.observation", + "locator": "document", + "value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022custom-gem\u0022,\u0022version\u0022:\u00221.0.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022artifact\u0022:\u0022vendor/cache/custom-gem-1.0.0.gem\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022puma\u0022,\u0022version\u0022:\u00226.4.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rake\u0022,\u0022version\u0022:\u002213.1.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022custom-gem\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022puma\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rake\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:true,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:true,\u0022jobSchedulers\u0022:[\u0022activejob\u0022,\u0022clockwork\u0022,\u0022resque\u0022,\u0022sidekiq\u0022]}}", + "sha256": "sha256:3818fd050909977a44167565a419a307777bc38998ad49d6a41c054982c6f46e" + } + ] + }, { "analyzerId": "ruby", "componentKey": "purl::pkg:gem/custom-gem@1.0.0", @@ -8,6 +32,7 @@ "type": "gem", "usedByEntrypoint": true, "metadata": { + "artifact": "vendor/cache/custom-gem-1.0.0.gem", "capability.exec": "true", "capability.net": "true", "capability.scheduler": "activejob;clockwork;resque;sidekiq", @@ -16,7 +41,8 @@ "capability.scheduler.resque": "true", "capability.scheduler.sidekiq": "true", "capability.serialization": "true", - "declaredOnly": "true", + "declaredOnly": "false", + "groups": "default", "lockfile": "vendor/cache/custom-gem-1.0.0.gem", "runtime.entrypoints": "app/main.rb", "runtime.files": "app/main.rb", @@ -27,7 +53,7 @@ "evidence": [ { "kind": "file", - "source": "Gemfile.lock", + "source": "custom-gem-1.0.0.gem", "locator": "vendor/cache/custom-gem-1.0.0.gem" } ] @@ -50,6 +76,7 @@ "capability.scheduler.sidekiq": "true", "capability.serialization": "true", "declaredOnly": "true", + "groups": "default", "lockfile": "Gemfile.lock", "runtime.entrypoints": "app/main.rb", "runtime.files": "app/main.rb", @@ -83,6 +110,7 @@ "capability.scheduler.sidekiq": "true", "capability.serialization": "true", "declaredOnly": "true", + "groups": "default", "lockfile": "Gemfile.lock", "runtime.entrypoints": "app/main.rb", "runtime.files": "app/main.rb", @@ -98,4 +126,4 @@ } ] } -] +] \ No newline at end of file diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile new file mode 100644 index 000000000..34ba44c6d --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile @@ -0,0 +1,12 @@ +source "https://rubygems.org" + +git "https://github.com/example/git-gem.git", branch: "main" do + gem "git-gem" +end + +gem "httparty", "~> 0.21.0" + +path "vendor/plugins/path-gem" do + gem "path-gem", "~> 2.1" +end + diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile.lock b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile.lock new file mode 100644 index 000000000..121b9578e --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/Gemfile.lock @@ -0,0 +1,31 @@ +GIT + remote: https://github.com/example/git-gem.git + revision: 0123456789abcdef0123456789abcdef01234567 + branch: main + specs: + git-gem (0.5.0) + +PATH + remote: vendor/plugins/path-gem + specs: + path-gem (2.1.3) + rake (~> 13.0) + +GEM + remote: https://rubygems.org/ + specs: + httparty (0.21.0) + multi_xml (~> 0.5) + multi_xml (0.6.0) + rake (13.1.0) + +PLATFORMS + ruby + +DEPENDENCIES + git-gem! + httparty (~> 0.21.0) + path-gem (~> 2.1)! + +BUNDLED WITH + 2.5.10 diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/app/main.rb b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/app/main.rb new file mode 100644 index 000000000..62ba96c5f --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/app/main.rb @@ -0,0 +1,7 @@ +require "git-gem" +require "path-gem" +require "httparty" + +puts GitGem.version +puts PathGem::Runner.new.perform +puts HTTParty.get("https://example.invalid") diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/expected.json b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/expected.json new file mode 100644 index 000000000..ec51e0b85 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/expected.json @@ -0,0 +1,154 @@ +[ + { + "analyzerId": "ruby", + "componentKey": "observation::ruby", + "name": "Ruby Observation Summary", + "type": "ruby-observation", + "usedByEntrypoint": false, + "metadata": { + "ruby.observation.capability.exec": "false", + "ruby.observation.capability.net": "true", + "ruby.observation.capability.schedulers": "0", + "ruby.observation.capability.serialization": "false", + "ruby.observation.packages": "5", + "ruby.observation.runtime_edges": "3" + }, + "evidence": [ + { + "kind": "derived", + "source": "ruby.observation", + "locator": "document", + "value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022git-gem\u0022,\u0022version\u0022:\u00220.5.0\u0022,\u0022source\u0022:\u0022git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022httparty\u0022,\u0022version\u0022:\u00220.21.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022multi_xml\u0022,\u0022version\u0022:\u00220.6.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022path-gem\u0022,\u0022version\u0022:\u00222.1.3\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/path-gem-2.1.3.gem\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rake\u0022,\u0022version\u0022:\u002213.1.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022git-gem\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022httparty\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022path-gem\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[]}}", + "sha256": "sha256:1cd5eb20a226916b9d1acbfc7182845a3ebca8284c7f558b23b7e87395e0a2c2" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/git-gem@0.5.0", + "purl": "pkg:gem/git-gem@0.5.0", + "name": "git-gem", + "version": "0.5.0", + "type": "gem", + "usedByEntrypoint": true, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "runtime.entrypoints": "app/main.rb", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/httparty@0.21.0", + "purl": "pkg:gem/httparty@0.21.0", + "name": "httparty", + "version": "0.21.0", + "type": "gem", + "usedByEntrypoint": true, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "runtime.entrypoints": "app/main.rb", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/multi_xml@0.6.0", + "purl": "pkg:gem/multi_xml@0.6.0", + "name": "multi_xml", + "version": "0.6.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/path-gem@2.1.3", + "purl": "pkg:gem/path-gem@2.1.3", + "name": "path-gem", + "version": "2.1.3", + "type": "gem", + "usedByEntrypoint": true, + "metadata": { + "artifact": "vendor/cache/path-gem-2.1.3.gem", + "capability.net": "true", + "declaredOnly": "false", + "groups": "default", + "lockfile": "Gemfile.lock", + "runtime.entrypoints": "app/main.rb", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "vendor-cache" + }, + "evidence": [ + { + "kind": "file", + "source": "path-gem-2.1.3.gem", + "locator": "vendor/cache/path-gem-2.1.3.gem" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/rake@13.1.0", + "purl": "pkg:gem/rake@13.1.0", + "name": "rake", + "version": "13.1.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "capability.net": "true", + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + } +] \ No newline at end of file diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/vendor/cache/path-gem-2.1.3.gem b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/git-sources/vendor/cache/path-gem-2.1.3.gem new file mode 100644 index 000000000..e69de29bb diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/workspace/expected.json b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/workspace/expected.json index fe51488c7..de5ac5170 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/workspace/expected.json +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/ruby/workspace/expected.json @@ -1 +1,193 @@ -[] +[ + { + "analyzerId": "ruby", + "componentKey": "observation::ruby", + "name": "Ruby Observation Summary", + "type": "ruby-observation", + "usedByEntrypoint": false, + "metadata": { + "ruby.observation.capability.exec": "false", + "ruby.observation.capability.net": "false", + "ruby.observation.capability.schedulers": "0", + "ruby.observation.capability.serialization": "false", + "ruby.observation.packages": "7", + "ruby.observation.runtime_edges": "4" + }, + "evidence": [ + { + "kind": "derived", + "source": "ruby.observation", + "locator": "document", + "value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022api-gem\u0022,\u0022version\u0022:\u00220.1.0\u0022,\u0022source\u0022:\u0022apps\u0022,\u0022declaredOnly\u0022:false,\u0022artifact\u0022:\u0022apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022bootsnap\u0022,\u0022version\u0022:\u00221.18.4\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022apps/api/Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00221.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022,\u0022test\u0022]},{\u0022name\u0022:\u0022puma\u0022,\u0022version\u0022:\u00226.4.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022console\u0022,\u0022production\u0022]},{\u0022name\u0022:\u0022rails\u0022,\u0022version\u0022:\u00227.1.3\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rubocop\u0022,\u0022version\u0022:\u00221.60.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022,\u0022test\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.4\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022apps/api/Gemfile.lock\u0022,\u0022groups\u0022:[\u0022jobs\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022bootsnap\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022puma\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rails\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:false,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[]}}", + "sha256": "sha256:6f9996b97be3dbbf3a18c2cb91624d45ddd16b2a374dd4a7f48049f5192114e2" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/api-gem@0.1.0", + "purl": "pkg:gem/api-gem@0.1.0", + "name": "api-gem", + "version": "0.1.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "artifact": "apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0", + "declaredOnly": "false", + "groups": "default", + "lockfile": "apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0", + "source": "apps" + }, + "evidence": [ + { + "kind": "file", + "source": "api-gem-0.1.0", + "locator": "apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/bootsnap@1.18.4", + "purl": "pkg:gem/bootsnap@1.18.4", + "name": "bootsnap", + "version": "1.18.4", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "apps/api/Gemfile.lock", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "apps/api/Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/pry@1.0.0", + "purl": "pkg:gem/pry@1.0.0", + "name": "pry", + "version": "1.0.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "development;test", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/puma@6.4.2", + "purl": "pkg:gem/puma@6.4.2", + "name": "puma", + "version": "6.4.2", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "console;production", + "lockfile": "Gemfile.lock", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/rails@7.1.3", + "purl": "pkg:gem/rails@7.1.3", + "name": "rails", + "version": "7.1.3", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/rubocop@1.60.0", + "purl": "pkg:gem/rubocop@1.60.0", + "name": "rubocop", + "version": "1.60.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "development;test", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/sidekiq@7.2.4", + "purl": "pkg:gem/sidekiq@7.2.4", + "name": "sidekiq", + "version": "7.2.4", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "jobs", + "lockfile": "apps/api/Gemfile.lock", + "runtime.files": "app/main.rb", + "runtime.reasons": "require-static", + "runtime.used": "true", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "apps/api/Gemfile.lock" + } + ] + } +] \ No newline at end of file diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Lang/Ruby/RubyLanguageAnalyzerTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Lang/Ruby/RubyLanguageAnalyzerTests.cs index fb6e5198d..4e3ac8ab4 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Lang/Ruby/RubyLanguageAnalyzerTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/Lang/Ruby/RubyLanguageAnalyzerTests.cs @@ -34,4 +34,19 @@ public sealed class RubyLanguageAnalyzerTests new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() }, cancellationToken: TestContext.Current.CancellationToken); } + + [Fact] + public async Task GitAndPathSourcesAsync() + { + var fixture = TestPaths.ResolveFixture("lang", "ruby", "git-sources"); + var golden = Path.Combine(fixture, "expected.json"); + var usageHints = new LanguageUsageHints(new[] { Path.Combine(fixture, "app", "main.rb") }); + + await LanguageAnalyzerTestHarness.AssertDeterministicAsync( + fixture, + golden, + new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() }, + cancellationToken: TestContext.Current.CancellationToken, + usageHints: usageHints); + } }