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 356d4793c..3d7e3fd9a 100644 Binary files a/ops/offline-kit/__pycache__/build_offline_kit.cpython-312.pyc and b/ops/offline-kit/__pycache__/build_offline_kit.cpython-312.pyc differ diff --git a/ops/offline-kit/__pycache__/mirror_debug_store.cpython-312.pyc b/ops/offline-kit/__pycache__/mirror_debug_store.cpython-312.pyc deleted file mode 100644 index 9c1fe6106..000000000 Binary files a/ops/offline-kit/__pycache__/mirror_debug_store.cpython-312.pyc and /dev/null differ 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 bb43b26e3..000000000 Binary files a/ops/offline-kit/__pycache__/test_build_offline_kit.cpython-312.pyc and /dev/null differ 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); + } }