Refactor and enhance scanner worker functionality
- Cleaned up code formatting and organization across multiple files for improved readability. - Introduced `OsScanAnalyzerDispatcher` to handle OS analyzer execution and plugin loading. - Updated `ScanJobContext` to include an `Analysis` property for storing scan results. - Enhanced `ScanJobProcessor` to utilize the new `OsScanAnalyzerDispatcher`. - Improved logging and error handling in `ScanProgressReporter` for better traceability. - Updated project dependencies and added references to new analyzer plugins. - Revised task documentation to reflect current status and dependencies.
This commit is contained in:
		| @@ -36,6 +36,21 @@ env: | |||||||
|   RUNNER_TOOL_CACHE: /toolcache |   RUNNER_TOOL_CACHE: /toolcache | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|  |   profile-validation: | ||||||
|  |     runs-on: ubuntu-22.04 | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout repository | ||||||
|  |         uses: actions/checkout@v4 | ||||||
|  |  | ||||||
|  |       - name: Install Helm | ||||||
|  |         run: | | ||||||
|  |           curl -fsSL https://get.helm.sh/helm-v3.16.0-linux-amd64.tar.gz -o /tmp/helm.tgz | ||||||
|  |           tar -xzf /tmp/helm.tgz -C /tmp | ||||||
|  |           sudo install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm | ||||||
|  |  | ||||||
|  |       - name: Validate deployment profiles | ||||||
|  |         run: ./deploy/tools/validate-profiles.sh | ||||||
|  |  | ||||||
|   build-test: |   build-test: | ||||||
|     runs-on: ubuntu-22.04 |     runs-on: ubuntu-22.04 | ||||||
|     environment: ${{ github.event_name == 'pull_request' && 'preview' || 'staging' }} |     environment: ${{ github.event_name == 'pull_request' && 'preview' || 'staging' }} | ||||||
| @@ -70,6 +85,73 @@ jobs: | |||||||
|             --logger "trx;LogFileName=stellaops-feedser-tests.trx" \ |             --logger "trx;LogFileName=stellaops-feedser-tests.trx" \ | ||||||
|             --results-directory "$TEST_RESULTS_DIR" |             --results-directory "$TEST_RESULTS_DIR" | ||||||
|  |  | ||||||
|  |       - name: Publish BuildX SBOM generator | ||||||
|  |         run: | | ||||||
|  |           dotnet publish src/StellaOps.Scanner.Sbomer.BuildXPlugin/StellaOps.Scanner.Sbomer.BuildXPlugin.csproj \ | ||||||
|  |             --configuration $BUILD_CONFIGURATION \ | ||||||
|  |             --output out/buildx | ||||||
|  |  | ||||||
|  |       - name: Verify BuildX descriptor determinism | ||||||
|  |         run: | | ||||||
|  |           dotnet out/buildx/StellaOps.Scanner.Sbomer.BuildXPlugin.dll handshake \ | ||||||
|  |             --manifest out/buildx \ | ||||||
|  |             --cas out/cas | ||||||
|  |  | ||||||
|  |           cat <<'JSON' > out/buildx-sbom.cdx.json | ||||||
|  | {"bomFormat":"CycloneDX","specVersion":"1.5"} | ||||||
|  | JSON | ||||||
|  |  | ||||||
|  |           dotnet out/buildx/StellaOps.Scanner.Sbomer.BuildXPlugin.dll descriptor \ | ||||||
|  |             --manifest out/buildx \ | ||||||
|  |             --image sha256:5c2c5bfe0d4d77f1a0f9866fd415dd8da5b62af05d7c3d4b53f28de3ebef0101 \ | ||||||
|  |             --sbom out/buildx-sbom.cdx.json \ | ||||||
|  |             --sbom-name buildx-sbom.cdx.json \ | ||||||
|  |             --artifact-type application/vnd.stellaops.sbom.layer+json \ | ||||||
|  |             --sbom-format cyclonedx-json \ | ||||||
|  |             --sbom-kind inventory \ | ||||||
|  |             --repository ${{ github.repository }} \ | ||||||
|  |             --build-ref ${{ github.sha }} \ | ||||||
|  |             > out/buildx-descriptor.json | ||||||
|  |  | ||||||
|  |           dotnet out/buildx/StellaOps.Scanner.Sbomer.BuildXPlugin.dll descriptor \ | ||||||
|  |             --manifest out/buildx \ | ||||||
|  |             --image sha256:5c2c5bfe0d4d77f1a0f9866fd415dd8da5b62af05d7c3d4b53f28de3ebef0101 \ | ||||||
|  |             --sbom out/buildx-sbom.cdx.json \ | ||||||
|  |             --sbom-name buildx-sbom.cdx.json \ | ||||||
|  |             --artifact-type application/vnd.stellaops.sbom.layer+json \ | ||||||
|  |             --sbom-format cyclonedx-json \ | ||||||
|  |             --sbom-kind inventory \ | ||||||
|  |             --repository ${{ github.repository }} \ | ||||||
|  |             --build-ref ${{ github.sha }} \ | ||||||
|  |             > out/buildx-descriptor-repeat.json | ||||||
|  |  | ||||||
|  |           python - <<'PY' | ||||||
|  | import json, sys | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | def normalize(path: str) -> dict: | ||||||
|  |     data = json.loads(Path(path).read_text(encoding='utf-8')) | ||||||
|  |     data.pop('generatedAt', None) | ||||||
|  |     return data | ||||||
|  |  | ||||||
|  | baseline = normalize('out/buildx-descriptor.json') | ||||||
|  | repeat = normalize('out/buildx-descriptor-repeat.json') | ||||||
|  |  | ||||||
|  | if baseline != repeat: | ||||||
|  |     sys.exit('BuildX descriptor output changed between runs.') | ||||||
|  | PY | ||||||
|  |  | ||||||
|  |       - name: Upload BuildX determinism artifacts | ||||||
|  |         uses: actions/upload-artifact@v4 | ||||||
|  |         with: | ||||||
|  |           name: buildx-determinism | ||||||
|  |           path: | | ||||||
|  |             out/buildx-descriptor.json | ||||||
|  |             out/buildx-descriptor-repeat.json | ||||||
|  |             out/buildx-sbom.cdx.json | ||||||
|  |           if-no-files-found: error | ||||||
|  |           retention-days: 7 | ||||||
|  |  | ||||||
|       - name: Publish Feedser web service |       - name: Publish Feedser web service | ||||||
|         run: | |         run: | | ||||||
|           mkdir -p "$PUBLISH_DIR" |           mkdir -p "$PUBLISH_DIR" | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								SPRINTS.md
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								SPRINTS.md
									
									
									
									
									
								
							| @@ -144,8 +144,8 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | |||||||
| | Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | DONE (2025-10-18) | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-002 | Attestation fetch & verify loop – download DSSE attestations, trigger verification, handle retries/backoff, persist raw statements. | | | Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | DONE (2025-10-18) | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-002 | Attestation fetch & verify loop – download DSSE attestations, trigger verification, handle retries/backoff, persist raw statements. | | ||||||
| | Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | DONE (2025-10-18) | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-003 | Provenance metadata & policy hooks – emit image, subject digest, issuer, and trust metadata for policy weighting/logging. | | | Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | DONE (2025-10-18) | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-003 | Provenance metadata & policy hooks – emit image, subject digest, issuer, and trust metadata for policy weighting/logging. | | ||||||
| | Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Cli/TASKS.md | DONE (2025-10-18) | DevEx/CLI | EXCITITOR-CLI-01-001 | Add `excititor` CLI verbs bridging to WebService with consistent auth and offline UX. | | | Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Cli/TASKS.md | DONE (2025-10-18) | DevEx/CLI | EXCITITOR-CLI-01-001 | Add `excititor` CLI verbs bridging to WebService with consistent auth and offline UX. | | ||||||
| | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Core/TASKS.md | TODO | Team Excititor Core & Policy | EXCITITOR-CORE-02-001 | Context signal schema prep – extend consensus models with severity/KEV/EPSS fields and update canonical serializers. | | | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Core/TASKS.md | DONE (2025-10-19) | Team Excititor Core & Policy | EXCITITOR-CORE-02-001 | Context signal schema prep – extend consensus models with severity/KEV/EPSS fields and update canonical serializers. | | ||||||
| | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Policy/TASKS.md | TODO | Team Excititor Policy | EXCITITOR-POLICY-02-001 | Scoring coefficients & weight ceilings – add α/β options, weight boosts, and validation guidance. | | | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Policy/TASKS.md | DONE (2025-10-19) | Team Excititor Policy | EXCITITOR-POLICY-02-001 | Scoring coefficients & weight ceilings – add α/β options, weight boosts, and validation guidance. | | ||||||
| | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Team Excititor Storage | EXCITITOR-STORAGE-02-001 | Statement events & scoring signals – create immutable VEX statement store plus consensus extensions with indexes/migrations. | | | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Team Excititor Storage | EXCITITOR-STORAGE-02-001 | Statement events & scoring signals – create immutable VEX statement store plus consensus extensions with indexes/migrations. | | ||||||
| | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-004 | Resolve API & signed responses – expose `/excititor/resolve`, return signed consensus/score envelopes, document auth. | | | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-004 | Resolve API & signed responses – expose `/excititor/resolve`, return signed consensus/score envelopes, document auth. | | ||||||
| | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-005 | Mirror distribution endpoints – expose download APIs for downstream Excititor instances. | | | Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-005 | Mirror distribution endpoints – expose download APIs for downstream Excititor instances. | | ||||||
| @@ -171,9 +171,11 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | |||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | DONE (2025-10-18) | Team Scanner Core | SCANNER-CORE-09-501 | Define shared DTOs (ScanJob, ProgressEvent), error taxonomy, and deterministic ID/timestamp helpers aligning with `ARCHITECTURE_SCANNER.md` §3–§4. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | DONE (2025-10-18) | Team Scanner Core | SCANNER-CORE-09-501 | Define shared DTOs (ScanJob, ProgressEvent), error taxonomy, and deterministic ID/timestamp helpers aligning with `ARCHITECTURE_SCANNER.md` §3–§4. | | ||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | DONE (2025-10-18) | Team Scanner Core | SCANNER-CORE-09-502 | Observability helpers (correlation IDs, logging scopes, metric namespacing, deterministic hashes) consumed by WebService/Worker. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | DONE (2025-10-18) | Team Scanner Core | SCANNER-CORE-09-502 | Observability helpers (correlation IDs, logging scopes, metric namespacing, deterministic hashes) consumed by WebService/Worker. | | ||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | DONE (2025-10-18) | Team Scanner Core | SCANNER-CORE-09-503 | Security utilities: Authority client factory, OpTok caching, DPoP verifier, restart-time plug-in guardrails for scanner components. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | DONE (2025-10-18) | Team Scanner Core | SCANNER-CORE-09-503 | Security utilities: Authority client factory, OpTok caching, DPoP verifier, restart-time plug-in guardrails for scanner components. | | ||||||
| | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE | BuildX Guild | SP9-BLDX-09-001 | Buildx driver scaffold + handshake with Scanner.Emit (local CAS). | | | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-001 | Buildx driver scaffold + handshake with Scanner.Emit (local CAS). | | ||||||
| | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE | BuildX Guild | SP9-BLDX-09-002 | OCI annotations + provenance hand-off to Attestor. | | | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-002 | OCI annotations + provenance hand-off to Attestor. | | ||||||
| | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE | BuildX Guild | SP9-BLDX-09-003 | CI demo: minimal SBOM push & backend report wiring. | | | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-003 | CI demo: minimal SBOM push & backend report wiring. | | ||||||
|  | | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-004 | Stabilize descriptor nonce derivation so repeated builds emit deterministic placeholders. | | ||||||
|  | | Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-005 | Integrate determinism guard into GitHub/Gitea workflows and archive proof artifacts. | | ||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-18) | Team Scanner WebService | SCANNER-WEB-09-101 | Minimal API host with Authority enforcement, health/ready endpoints, and restart-time plug-in loader per architecture §1, §4. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-18) | Team Scanner WebService | SCANNER-WEB-09-101 | Minimal API host with Authority enforcement, health/ready endpoints, and restart-time plug-in loader per architecture §1, §4. | | ||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-18) | Team Scanner WebService | SCANNER-WEB-09-102 | `/api/v1/scans` submission/status endpoints with deterministic IDs, validation, and cancellation support. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-18) | Team Scanner WebService | SCANNER-WEB-09-102 | `/api/v1/scans` submission/status endpoints with deterministic IDs, validation, and cancellation support. | | ||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-WEB-09-103 | Progress streaming (SSE/JSONL) with correlation IDs and ISO-8601 UTC timestamps, documented in API reference. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-WEB-09-103 | Progress streaming (SSE/JSONL) with correlation IDs and ISO-8601 UTC timestamps, documented in API reference. | | ||||||
| @@ -185,6 +187,7 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | |||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | DONE (2025-10-19) | Team Scanner Worker | SCANNER-WORKER-09-202 | Lease/heartbeat loop with retry+jitter, poison-job quarantine, structured logging. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | DONE (2025-10-19) | Team Scanner Worker | SCANNER-WORKER-09-202 | Lease/heartbeat loop with retry+jitter, poison-job quarantine, structured logging. | | ||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | DONE (2025-10-19) | Team Scanner Worker | SCANNER-WORKER-09-203 | Analyzer dispatch skeleton emitting deterministic stage progress and honoring cancellation tokens. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | DONE (2025-10-19) | Team Scanner Worker | SCANNER-WORKER-09-203 | Analyzer dispatch skeleton emitting deterministic stage progress and honoring cancellation tokens. | | ||||||
| | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | DONE (2025-10-19) | Team Scanner Worker | SCANNER-WORKER-09-204 | Worker metrics (queue latency, stage duration, failure counts) with OpenTelemetry resource wiring. | | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | DONE (2025-10-19) | Team Scanner Worker | SCANNER-WORKER-09-204 | Worker metrics (queue latency, stage duration, failure counts) with OpenTelemetry resource wiring. | | ||||||
|  | | Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | DONE (2025-10-19) | Team Scanner Worker | SCANNER-WORKER-09-205 | Harden heartbeat jitter so lease safety margin stays ≥3× and cover with regression tests + optional live queue smoke run. | | ||||||
| | Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE | Policy Guild | POLICY-CORE-09-001 | Policy schema + binder + diagnostics. | | | Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE | Policy Guild | POLICY-CORE-09-001 | Policy schema + binder + diagnostics. | | ||||||
| | Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE | Policy Guild | POLICY-CORE-09-002 | Policy snapshot store + revision digests. | | | Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE | Policy Guild | POLICY-CORE-09-002 | Policy snapshot store + revision digests. | | ||||||
| | Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE | Policy Guild | POLICY-CORE-09-003 | `/policy/preview` API (image digest → projected verdict diff). | | | Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE | Policy Guild | POLICY-CORE-09-003 | `/policy/preview` API (image digest → projected verdict diff). | | ||||||
|   | |||||||
| @@ -39,16 +39,16 @@ Durations are estimated work sizes (1 d ≈ one focused engineer day). Milesto | |||||||
| - Status: **IN PROGRESS (2025-10-19)** – Minimal host and `/api/v1/scans` endpoints delivered (SCANNER-WEB-09-101/102 done); progress streaming and policy/report surfaces remain. | - Status: **IN PROGRESS (2025-10-19)** – Minimal host and `/api/v1/scans` endpoints delivered (SCANNER-WEB-09-101/102 done); progress streaming and policy/report surfaces remain. | ||||||
|  |  | ||||||
| ### Group SP9-G5 — Worker Host (src/StellaOps.Scanner.Worker) ~1 w | ### Group SP9-G5 — Worker Host (src/StellaOps.Scanner.Worker) ~1 w | ||||||
| - Tasks: SCANNER-WORKER-09-201 (3 d), -202 (3 d), -203 (2 d), -204 (2 d) | - Tasks: SCANNER-WORKER-09-201 (3 d), -202 (3 d), -203 (2 d), -204 (2 d), -205 (1 d) | ||||||
| - Acceptance: job lease never drops <3× heartbeat; progress events deterministic. | - Acceptance: job lease never drops <3× heartbeat; progress events deterministic. | ||||||
| - Gate: `WorkerBasicScanScenario` integration recorded. | - Gate: `WorkerBasicScanScenario` integration recorded + optional live queue smoke validation. | ||||||
| - Status: **DONE (2025-10-19)** – Host bootstrap + authority wiring, heartbeat loop, deterministic stage pipeline, and metrics landed; `WorkerBasicScanScenarioTests` green. | - Status: **DONE (2025-10-19)** – Host bootstrap, heartbeat jitter clamp, deterministic stage pipeline, metrics, and Redis-backed smoke harness landed; `WorkerBasicScanScenarioTests` and `RedisWorkerSmokeTests` (flagged) green. | ||||||
|  |  | ||||||
| ### Group SP9-G6 — Buildx Plug-in (src/StellaOps.Scanner.Sbomer.BuildXPlugin) ~0.8 w | ### Group SP9-G6 — Buildx Plug-in (src/StellaOps.Scanner.Sbomer.BuildXPlugin) ~0.8 w | ||||||
| - Tasks: SP9-BLDX-09-001 (3 d), SP9-BLDX-09-002 (2 d), SP9-BLDX-09-003 (2 d) | - Tasks: SP9-BLDX-09-001 (3 d), SP9-BLDX-09-002 (2 d), SP9-BLDX-09-003 (2 d), SP9-BLDX-09-004 (2 d), SP9-BLDX-09-005 (1 d) | ||||||
| - Acceptance: build-time overhead ≤300 ms/layer on 4 vCPU; CAS handshake reliable in CI sample. | - Acceptance: build-time overhead ≤300 ms/layer on 4 vCPU; CAS handshake reliable in CI sample. | ||||||
| - Gate: buildx demo workflow artifact + quickstart doc. | - Gate: buildx demo workflow artifact + quickstart doc + determinism regression guard in CI. | ||||||
| - Status: **DONE** (2025-10-19) — manifest+CAS scaffold, descriptor/Attestor hand-off, GitHub demo workflow, and quickstart committed. | - Status: **DONE (2025-10-19)** — manifest+CAS scaffold, descriptor/Attestor hand-off, GitHub/Gitea determinism workflows, quickstart update, and golden tests committed. | ||||||
|  |  | ||||||
| ### Group SP9-G7 — Policy Engine Core (src/StellaOps.Policy) ~1 w | ### Group SP9-G7 — Policy Engine Core (src/StellaOps.Policy) ~1 w | ||||||
| - Tasks: POLICY-CORE-09-001 (2 d) ✅, -002 (3 d) ✅, -003 (3 d) ✅, -004 (3 d), -005 (4 d), -006 (2 d) | - Tasks: POLICY-CORE-09-001 (2 d) ✅, -002 (3 d) ✅, -003 (3 d) ✅, -004 (3 d), -005 (4 d), -006 (2 d) | ||||||
|   | |||||||
| @@ -162,6 +162,10 @@ GET  /catalog/artifacts/{id}       → { meta } | |||||||
| GET  /healthz | /readyz | /metrics | GET  /healthz | /readyz | /metrics | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ### Report events | ||||||
|  |  | ||||||
|  | When `scanner.events.enabled = true`, the WebService serialises the signed report (canonical JSON + DSSE envelope) with `NotifyCanonicalJsonSerializer` and publishes two Redis Stream entries (`scanner.report.ready`, `scanner.scan.completed`) to the configured stream (default `stella.events`). The stream fields carry the whole envelope plus lightweight headers (`kind`, `tenant`, `ts`) so Notify and UI timelines can consume the event bus without recomputing signatures. Publish timeouts and bounded stream length are controlled via `scanner:events:publishTimeoutSeconds` and `scanner:events:maxStreamLength`. If the queue driver is already Redis and no explicit events DSN is provided, the host reuses the queue connection and auto-enables event emission so deployments get live envelopes without extra wiring. | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
| ## 5) Execution flow (Worker) | ## 5) Execution flow (Worker) | ||||||
| @@ -433,6 +437,26 @@ ResolveEntrypoint(ImageConfig cfg, RootFs fs): | |||||||
|   return Unknown(reason) |   return Unknown(reason) | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ### Appendix A.1 — EntryTrace Explainability | ||||||
|  |  | ||||||
|  | EntryTrace emits structured diagnostics and metrics so operators can quickly understand why resolution succeeded or degraded: | ||||||
|  |  | ||||||
|  | | Reason | Description | Typical Mitigation | | ||||||
|  | |--------|-------------|--------------------| | ||||||
|  | | `CommandNotFound` | A command referenced in the script cannot be located in the layered root filesystem or `PATH`. | Ensure binaries exist in the image or extend `PATH` hints. | | ||||||
|  | | `MissingFile` | `source`/`.`/`run-parts` targets are missing. | Bundle the script or guard the include. | | ||||||
|  | | `DynamicEnvironmentReference` | Path depends on `$VARS` that are unknown at scan time. | Provide defaults via scan metadata or accept partial usage. | | ||||||
|  | | `RecursionLimitReached` | Nested includes exceeded the analyzer depth limit (default 64). | Flatten indirection or increase the limit in options. | | ||||||
|  | | `RunPartsEmpty` | `run-parts` directory contained no executable entries. | Remove empty directories or ignore if intentional. | | ||||||
|  | | `JarNotFound` / `ModuleNotFound` | Java/Python targets missing, preventing interpreter tracing. | Ship the jar/module with the image or adjust the launcher. | | ||||||
|  |  | ||||||
|  | Diagnostics drive two metrics published by `EntryTraceMetrics`: | ||||||
|  |  | ||||||
|  | - `entrytrace_resolutions_total{outcome}` — resolution attempts segmented by outcome (`resolved`, `partiallyresolved`, `unresolved`). | ||||||
|  | - `entrytrace_unresolved_total{reason}` — diagnostic counts keyed by reason. | ||||||
|  |  | ||||||
|  | Structured logs include `entrytrace.path`, `entrytrace.command`, `entrytrace.reason`, and `entrytrace.depth`, all correlated with scan/job IDs. Timestamps are normalized to UTC (microsecond precision) to keep DSSE attestations and UI traces explainable. | ||||||
|  |  | ||||||
| ### Appendix B — BOM‑Index sidecar | ### Appendix B — BOM‑Index sidecar | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
|   | |||||||
| @@ -67,7 +67,7 @@ The command performs a deterministic probe write (`sha256`) into the provided CA | |||||||
| The output JSON captures: | The output JSON captures: | ||||||
|  |  | ||||||
| - OCI artifact descriptor including size, digest, and annotations (`org.stellaops.*`). | - OCI artifact descriptor including size, digest, and annotations (`org.stellaops.*`). | ||||||
| - Provenance placeholder (`expectedDsseSha256`, `nonce`, `attestorUri` when provided). | - Provenance placeholder (`expectedDsseSha256`, `nonce`, `attestorUri` when provided). `nonce` is derived deterministically from the image + SBOM metadata so repeated runs produce identical placeholders for identical inputs. | ||||||
| - Generator metadata and deterministic timestamps. | - Generator metadata and deterministic timestamps. | ||||||
|  |  | ||||||
| ## 5. (Optional) Send the placeholder to an Attestor | ## 5. (Optional) Send the placeholder to an Attestor | ||||||
| @@ -110,7 +110,7 @@ Set `STELLAOPS_ATTESTOR_TOKEN` (or pass `--attestor-token`) when the Attestor re | |||||||
|  |  | ||||||
| A reusable GitHub Actions workflow is provided under `samples/ci/buildx-demo/github-actions-buildx-demo.yml`. It publishes the plug-in, runs the handshake, builds the demo image, emits a descriptor, and uploads both the descriptor and the mock-Attestor request as artefacts. | A reusable GitHub Actions workflow is provided under `samples/ci/buildx-demo/github-actions-buildx-demo.yml`. It publishes the plug-in, runs the handshake, builds the demo image, emits a descriptor, and uploads both the descriptor and the mock-Attestor request as artefacts. | ||||||
|  |  | ||||||
| Add the workflow to your repository (or call it via `workflow_call`) and adjust the SBOM path + Attestor URL as needed. | Add the workflow to your repository (or call it via `workflow_call`) and adjust the SBOM path + Attestor URL as needed. The workflow also re-runs the `descriptor` command and diffs the results (ignoring the `generatedAt` timestamp) so you catch regressions that would break deterministic CI use. | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,191 @@ | |||||||
|  | { | ||||||
|  |   "runtimeTarget": { | ||||||
|  |     "name": ".NETCoreApp,Version=v10.0", | ||||||
|  |     "signature": "" | ||||||
|  |   }, | ||||||
|  |   "compilationOptions": {}, | ||||||
|  |   "targets": { | ||||||
|  |     ".NETCoreApp,Version=v10.0": { | ||||||
|  |       "StellaOps.Scanner.EntryTrace/1.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Logging.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Options": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0", | ||||||
|  |           "StellaOps.Plugin": "1.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "StellaOps.Scanner.EntryTrace.dll": {} | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.Configuration.Abstractions/9.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.Primitives": "9.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.Configuration.Abstractions.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.Configuration.Binder/9.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.Configuration.Abstractions": "9.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.Configuration.Binder.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.DependencyInjection.Abstractions/9.0.0": { | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.Logging.Abstractions/9.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.Logging.Abstractions.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.Options/9.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Primitives": "9.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.Options.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.Options.ConfigurationExtensions/9.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Configuration.Binder": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Options": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Primitives": "9.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.Primitives/9.0.0": { | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.Primitives.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "StellaOps.DependencyInjection/1.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "StellaOps.DependencyInjection.dll": { | ||||||
|  |             "assemblyVersion": "1.0.0.0", | ||||||
|  |             "fileVersion": "1.0.0.0" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "StellaOps.Plugin/1.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", | ||||||
|  |           "Microsoft.Extensions.Logging.Abstractions": "9.0.0", | ||||||
|  |           "StellaOps.DependencyInjection": "1.0.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "StellaOps.Plugin.dll": { | ||||||
|  |             "assemblyVersion": "1.0.0.0", | ||||||
|  |             "fileVersion": "1.0.0.0" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "libraries": { | ||||||
|  |     "StellaOps.Scanner.EntryTrace/1.0.0": { | ||||||
|  |       "type": "project", | ||||||
|  |       "serviceable": false, | ||||||
|  |       "sha512": "" | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.Configuration.Abstractions/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==", | ||||||
|  |       "path": "microsoft.extensions.configuration.abstractions/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.configuration.abstractions.9.0.0.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.Configuration.Binder/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==", | ||||||
|  |       "path": "microsoft.extensions.configuration.binder/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.configuration.binder.9.0.0.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.DependencyInjection.Abstractions/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==", | ||||||
|  |       "path": "microsoft.extensions.dependencyinjection.abstractions/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.dependencyinjection.abstractions.9.0.0.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.Logging.Abstractions/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", | ||||||
|  |       "path": "microsoft.extensions.logging.abstractions/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.logging.abstractions.9.0.0.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.Options/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", | ||||||
|  |       "path": "microsoft.extensions.options/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.options.9.0.0.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.Options.ConfigurationExtensions/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==", | ||||||
|  |       "path": "microsoft.extensions.options.configurationextensions/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.options.configurationextensions.9.0.0.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.Primitives/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==", | ||||||
|  |       "path": "microsoft.extensions.primitives/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.primitives.9.0.0.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "StellaOps.DependencyInjection/1.0.0": { | ||||||
|  |       "type": "project", | ||||||
|  |       "serviceable": false, | ||||||
|  |       "sha512": "" | ||||||
|  |     }, | ||||||
|  |     "StellaOps.Plugin/1.0.0": { | ||||||
|  |       "type": "project", | ||||||
|  |       "serviceable": false, | ||||||
|  |       "sha512": "" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,22 @@ | |||||||
|  | { | ||||||
|  |   "schemaVersion": "1.0", | ||||||
|  |   "id": "stellaops.entrytrace.analyzers", | ||||||
|  |   "displayName": "StellaOps EntryTrace Analyzer Pack", | ||||||
|  |   "version": "0.1.0-alpha", | ||||||
|  |   "requiresRestart": true, | ||||||
|  |   "entryPoint": { | ||||||
|  |     "type": "dotnet", | ||||||
|  |     "executable": "StellaOps.Scanner.EntryTrace.dll", | ||||||
|  |     "arguments": [ | ||||||
|  |       "handshake" | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   "capabilities": [ | ||||||
|  |     "entrytrace", | ||||||
|  |     "analyzer" | ||||||
|  |   ], | ||||||
|  |   "metadata": { | ||||||
|  |     "org.stellaops.plugin.kind": "entrytrace-analyzer", | ||||||
|  |     "org.stellaops.restart.required": "true" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -99,6 +99,38 @@ PY | |||||||
|             --attestor http://127.0.0.1:8085/provenance \ |             --attestor http://127.0.0.1:8085/provenance \ | ||||||
|             > out/buildx-descriptor.json |             > out/buildx-descriptor.json | ||||||
|  |  | ||||||
|  |       - name: Verify descriptor determinism | ||||||
|  |         env: | ||||||
|  |           IMAGE_DIGEST: ${{ steps.digest.outputs.digest }} | ||||||
|  |         run: | | ||||||
|  |           dotnet out/buildx/StellaOps.Scanner.Sbomer.BuildXPlugin.dll descriptor \ | ||||||
|  |             --manifest out/buildx \ | ||||||
|  |             --image "$IMAGE_DIGEST" \ | ||||||
|  |             --sbom out/buildx-sbom.cdx.json \ | ||||||
|  |             --sbom-name buildx-sbom.cdx.json \ | ||||||
|  |             --artifact-type application/vnd.stellaops.sbom.layer+json \ | ||||||
|  |             --sbom-format cyclonedx-json \ | ||||||
|  |             --sbom-kind inventory \ | ||||||
|  |             --repository ${{ github.repository }} \ | ||||||
|  |             --build-ref ${{ github.sha }} \ | ||||||
|  |             > out/buildx-descriptor-repeat.json | ||||||
|  |  | ||||||
|  |           python - <<'PY' | ||||||
|  | import json | ||||||
|  |  | ||||||
|  | def normalize(path: str) -> dict: | ||||||
|  |     with open(path, 'r', encoding='utf-8') as handle: | ||||||
|  |         data = json.load(handle) | ||||||
|  |     data.pop('generatedAt', None) | ||||||
|  |     return data | ||||||
|  |  | ||||||
|  | baseline = normalize('out/buildx-descriptor.json') | ||||||
|  | repeat = normalize('out/buildx-descriptor-repeat.json') | ||||||
|  |  | ||||||
|  | if baseline != repeat: | ||||||
|  |     raise SystemExit('Descriptor output changed between runs.') | ||||||
|  | PY | ||||||
|  |  | ||||||
|       - name: Stop mock Attestor |       - name: Stop mock Attestor | ||||||
|         if: always() |         if: always() | ||||||
|         run: | |         run: | | ||||||
| @@ -114,6 +146,7 @@ PY | |||||||
|             out/buildx-descriptor.json |             out/buildx-descriptor.json | ||||||
|             out/buildx-sbom.cdx.json |             out/buildx-sbom.cdx.json | ||||||
|             out/provenance-request.json |             out/provenance-request.json | ||||||
|  |             out/buildx-descriptor-repeat.json | ||||||
|  |  | ||||||
|       - name: Show descriptor summary |       - name: Show descriptor summary | ||||||
|         run: | |         run: | | ||||||
|   | |||||||
| @@ -0,0 +1,136 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  | using Microsoft.Extensions.Logging.Abstractions; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Scanner.EntryTrace.Diagnostics; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace.Tests; | ||||||
|  |  | ||||||
|  | public sealed class EntryTraceAnalyzerTests | ||||||
|  | { | ||||||
|  |     private static EntryTraceAnalyzer CreateAnalyzer() | ||||||
|  |     { | ||||||
|  |         var options = Options.Create(new EntryTraceAnalyzerOptions | ||||||
|  |         { | ||||||
|  |             MaxDepth = 32, | ||||||
|  |             FollowRunParts = true | ||||||
|  |         }); | ||||||
|  |         return new EntryTraceAnalyzer(options, new EntryTraceMetrics(), NullLogger<EntryTraceAnalyzer>.Instance); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task ResolveAsync_FollowsShellIncludeAndPythonModule() | ||||||
|  |     { | ||||||
|  |         var fs = new TestRootFileSystem(); | ||||||
|  |         fs.AddFile("/entrypoint.sh", """ | ||||||
|  |         #!/bin/sh | ||||||
|  |         source /opt/setup.sh | ||||||
|  |         exec python -m app.main --flag | ||||||
|  |         """); | ||||||
|  |         fs.AddFile("/opt/setup.sh", """ | ||||||
|  |         #!/bin/sh | ||||||
|  |         run-parts /opt/setup.d | ||||||
|  |         """); | ||||||
|  |         fs.AddDirectory("/opt/setup.d"); | ||||||
|  |         fs.AddFile("/opt/setup.d/001-node.sh", """ | ||||||
|  |         #!/bin/sh | ||||||
|  |         exec node /app/server.js | ||||||
|  |         """); | ||||||
|  |         fs.AddFile("/opt/setup.d/010-java.sh", """ | ||||||
|  |         #!/bin/sh | ||||||
|  |         java -jar /app/app.jar | ||||||
|  |         """); | ||||||
|  |         fs.AddFile("/usr/bin/python", "#!/usr/bin/env python3\n", executable: true); | ||||||
|  |         fs.AddFile("/usr/bin/node", "#!/usr/bin/env node\n", executable: true); | ||||||
|  |         fs.AddFile("/usr/bin/java", "", executable: true); | ||||||
|  |         fs.AddFile("/app/server.js", "console.log('hello');", executable: true); | ||||||
|  |         fs.AddFile("/app/app.jar", string.Empty, executable: true); | ||||||
|  |  | ||||||
|  |         var analyzer = CreateAnalyzer(); | ||||||
|  |         var context = new EntryTraceContext( | ||||||
|  |             fs, | ||||||
|  |             ImmutableDictionary<string, string>.Empty, | ||||||
|  |             ImmutableArray.Create("/usr/bin", "/usr/local/bin"), | ||||||
|  |             "/", | ||||||
|  |             "sha256:image", | ||||||
|  |             "scan-entrytrace-1", | ||||||
|  |             NullLogger.Instance); | ||||||
|  |  | ||||||
|  |         var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>()); | ||||||
|  |         var result = await analyzer.ResolveAsync(spec, context); | ||||||
|  |  | ||||||
|  |         Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome); | ||||||
|  |         Assert.Empty(result.Diagnostics); | ||||||
|  |  | ||||||
|  |         var nodeNames = result.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray(); | ||||||
|  |         Assert.Contains((EntryTraceNodeKind.Command, "/entrypoint.sh"), nodeNames); | ||||||
|  |         Assert.Contains((EntryTraceNodeKind.Include, "/opt/setup.sh"), nodeNames); | ||||||
|  |         Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "python"); | ||||||
|  |         Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "node"); | ||||||
|  |         Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "java"); | ||||||
|  |         Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.RunPartsDirectory && tuple.DisplayName == "/opt/setup.d"); | ||||||
|  |  | ||||||
|  |         Assert.Contains(result.Edges, edge => edge.Relationship == "python-module" && edge.Metadata is { } metadata && metadata.TryGetValue("module", out var module) && module == "app.main"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task ResolveAsync_RecordsDiagnosticsForMissingInclude() | ||||||
|  |     { | ||||||
|  |         var fs = new TestRootFileSystem(); | ||||||
|  |         fs.AddFile("/entrypoint.sh", """ | ||||||
|  |         #!/bin/sh | ||||||
|  |         source /missing/setup.sh | ||||||
|  |         exec /bin/true | ||||||
|  |         """); | ||||||
|  |         fs.AddFile("/bin/true", string.Empty, executable: true); | ||||||
|  |  | ||||||
|  |         var analyzer = CreateAnalyzer(); | ||||||
|  |         var context = new EntryTraceContext( | ||||||
|  |             fs, | ||||||
|  |             ImmutableDictionary<string, string>.Empty, | ||||||
|  |             ImmutableArray.Create("/bin"), | ||||||
|  |             "/", | ||||||
|  |             "sha256:image", | ||||||
|  |             "scan-entrytrace-2", | ||||||
|  |             NullLogger.Instance); | ||||||
|  |  | ||||||
|  |         var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>()); | ||||||
|  |         var result = await analyzer.ResolveAsync(spec, context); | ||||||
|  |  | ||||||
|  |         Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome); | ||||||
|  |         Assert.Single(result.Diagnostics); | ||||||
|  |         Assert.Equal(EntryTraceUnknownReason.MissingFile, result.Diagnostics[0].Reason); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task ResolveAsync_IsDeterministic() | ||||||
|  |     { | ||||||
|  |         var fs = new TestRootFileSystem(); | ||||||
|  |         fs.AddFile("/entrypoint.sh", """ | ||||||
|  |         #!/bin/sh | ||||||
|  |         exec node /app/index.js | ||||||
|  |         """); | ||||||
|  |         fs.AddFile("/usr/bin/node", string.Empty, executable: true); | ||||||
|  |         fs.AddFile("/app/index.js", "console.log('deterministic');", executable: true); | ||||||
|  |  | ||||||
|  |         var analyzer = CreateAnalyzer(); | ||||||
|  |         var context = new EntryTraceContext( | ||||||
|  |             fs, | ||||||
|  |             ImmutableDictionary<string, string>.Empty, | ||||||
|  |             ImmutableArray.Create("/usr/bin"), | ||||||
|  |             "/", | ||||||
|  |             "sha256:image", | ||||||
|  |             "scan-entrytrace-3", | ||||||
|  |             NullLogger.Instance); | ||||||
|  |  | ||||||
|  |         var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>()); | ||||||
|  |         var first = await analyzer.ResolveAsync(spec, context); | ||||||
|  |         var second = await analyzer.ResolveAsync(spec, context); | ||||||
|  |  | ||||||
|  |         Assert.Equal(first.Outcome, second.Outcome); | ||||||
|  |         Assert.Equal(first.Diagnostics, second.Diagnostics); | ||||||
|  |         Assert.Equal(first.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray(), second.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray()); | ||||||
|  |         Assert.Equal(first.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray(), | ||||||
|  |                      second.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray()); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										33
									
								
								src/StellaOps.Scanner.EntryTrace.Tests/ShellParserTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/StellaOps.Scanner.EntryTrace.Tests/ShellParserTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | using StellaOps.Scanner.EntryTrace.Parsing; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace.Tests; | ||||||
|  |  | ||||||
|  | public sealed class ShellParserTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public void Parse_ProducesDeterministicNodes() | ||||||
|  |     { | ||||||
|  |         const string script = """ | ||||||
|  |         #!/bin/sh | ||||||
|  |         source /opt/init.sh | ||||||
|  |         if [ -f /etc/profile ]; then | ||||||
|  |           . /etc/profile | ||||||
|  |         fi | ||||||
|  |  | ||||||
|  |         run-parts /etc/entry.d | ||||||
|  |         exec python -m app.main --flag | ||||||
|  |         """; | ||||||
|  |  | ||||||
|  |         var first = ShellParser.Parse(script); | ||||||
|  |         var second = ShellParser.Parse(script); | ||||||
|  |  | ||||||
|  |         Assert.Equal(first.Nodes.Length, second.Nodes.Length); | ||||||
|  |         var actual = first.Nodes.Select(n => n.GetType().Name).ToArray(); | ||||||
|  |         var expected = new[] { nameof(ShellIncludeNode), nameof(ShellIfNode), nameof(ShellRunPartsNode), nameof(ShellExecNode) }; | ||||||
|  |         Assert.Equal(expected, actual); | ||||||
|  |  | ||||||
|  |         var actualSecond = second.Nodes.Select(n => n.GetType().Name).ToArray(); | ||||||
|  |         Assert.Equal(expected, actualSecond); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk"> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <TargetFramework>net10.0</TargetFramework> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |   </PropertyGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <ProjectReference Include="../StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <None Update="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" /> | ||||||
|  |   </ItemGroup> | ||||||
|  | </Project> | ||||||
							
								
								
									
										180
									
								
								src/StellaOps.Scanner.EntryTrace.Tests/TestRootFileSystem.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								src/StellaOps.Scanner.EntryTrace.Tests/TestRootFileSystem.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,180 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Collections.Immutable; | ||||||
|  | using System.IO; | ||||||
|  | using StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace.Tests; | ||||||
|  |  | ||||||
|  | internal sealed class TestRootFileSystem : IRootFileSystem | ||||||
|  | { | ||||||
|  |     private readonly Dictionary<string, FileEntry> _entries = new(StringComparer.Ordinal); | ||||||
|  |     private readonly HashSet<string> _directories = new(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |     public TestRootFileSystem() | ||||||
|  |     { | ||||||
|  |         _directories.Add("/"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void AddFile(string path, string content, bool executable = true, string? layer = "sha256:layer-a") | ||||||
|  |     { | ||||||
|  |         var normalized = Normalize(path); | ||||||
|  |         var directory = Path.GetDirectoryName(normalized); | ||||||
|  |         if (!string.IsNullOrEmpty(directory)) | ||||||
|  |         { | ||||||
|  |             _directories.Add(directory!); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _entries[normalized] = new FileEntry(normalized, content, executable, layer, IsDirectory: false); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void AddDirectory(string path) | ||||||
|  |     { | ||||||
|  |         var normalized = Normalize(path); | ||||||
|  |         _directories.Add(normalized); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor) | ||||||
|  |     { | ||||||
|  |         if (name.Contains('/', StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             var normalized = Normalize(name); | ||||||
|  |             if (_entries.TryGetValue(normalized, out var file) && file.IsExecutable) | ||||||
|  |             { | ||||||
|  |                 descriptor = file.ToDescriptor(); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             descriptor = null!; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach (var prefix in searchPaths) | ||||||
|  |         { | ||||||
|  |             var candidate = Combine(prefix, name); | ||||||
|  |             if (_entries.TryGetValue(candidate, out var file) && file.IsExecutable) | ||||||
|  |             { | ||||||
|  |                 descriptor = file.ToDescriptor(); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         descriptor = null!; | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content) | ||||||
|  |     { | ||||||
|  |         var normalized = Normalize(path); | ||||||
|  |         if (_entries.TryGetValue(normalized, out var file)) | ||||||
|  |         { | ||||||
|  |             descriptor = file.ToDescriptor(); | ||||||
|  |             content = file.Content; | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         descriptor = null!; | ||||||
|  |         content = string.Empty; | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path) | ||||||
|  |     { | ||||||
|  |         var normalized = Normalize(path); | ||||||
|  |         var builder = ImmutableArray.CreateBuilder<RootFileDescriptor>(); | ||||||
|  |  | ||||||
|  |         foreach (var file in _entries.Values) | ||||||
|  |         { | ||||||
|  |             var directory = Normalize(Path.GetDirectoryName(file.Path) ?? "/"); | ||||||
|  |             if (string.Equals(directory, normalized, StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 builder.Add(file.ToDescriptor()); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return builder.ToImmutable(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public bool DirectoryExists(string path) | ||||||
|  |     { | ||||||
|  |         var normalized = Normalize(path); | ||||||
|  |         return _directories.Contains(normalized); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string Combine(string prefix, string name) | ||||||
|  |     { | ||||||
|  |         var normalizedPrefix = Normalize(prefix); | ||||||
|  |         if (normalizedPrefix == "/") | ||||||
|  |         { | ||||||
|  |             return Normalize("/" + name); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return Normalize($"{normalizedPrefix}/{name}"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string Normalize(string path) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(path)) | ||||||
|  |         { | ||||||
|  |             return "/"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var text = path.Replace('\\', '/').Trim(); | ||||||
|  |         if (!text.StartsWith("/", StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             text = "/" + text; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var parts = new List<string>(); | ||||||
|  |         foreach (var part in text.Split('/', StringSplitOptions.RemoveEmptyEntries)) | ||||||
|  |         { | ||||||
|  |             if (part == ".") | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (part == "..") | ||||||
|  |             { | ||||||
|  |                 if (parts.Count > 0) | ||||||
|  |                 { | ||||||
|  |                     parts.RemoveAt(parts.Count - 1); | ||||||
|  |                 } | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             parts.Add(part); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return "/" + string.Join('/', parts); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed record FileEntry(string Path, string Content, bool IsExecutable, string? Layer, bool IsDirectory) | ||||||
|  |     { | ||||||
|  |         public RootFileDescriptor ToDescriptor() | ||||||
|  |         { | ||||||
|  |             var shebang = ExtractShebang(Content); | ||||||
|  |             return new RootFileDescriptor(Path, Layer, IsExecutable, IsDirectory, shebang); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? ExtractShebang(string content) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(content)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         using var reader = new StringReader(content); | ||||||
|  |         var firstLine = reader.ReadLine(); | ||||||
|  |         if (firstLine is null) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!firstLine.StartsWith("#!", StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return firstLine[2..].Trim(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								src/StellaOps.Scanner.EntryTrace/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/StellaOps.Scanner.EntryTrace/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | # StellaOps.Scanner.EntryTrace — Agent Charter | ||||||
|  |  | ||||||
|  | ## Mission | ||||||
|  | Resolve container `ENTRYPOINT`/`CMD` chains into deterministic call graphs that fuel usage-aware SBOMs, policy explainability, and runtime drift detection. Implement the EntryTrace analyzers and expose them as restart-time plug-ins for the Scanner Worker. | ||||||
|  |  | ||||||
|  | ## Scope | ||||||
|  | - Parse POSIX/Bourne shell constructs (exec, command, case, if, source/run-parts) with deterministic AST output. | ||||||
|  | - Walk layered root filesystems to resolve PATH lookups, interpreter hand-offs (Python/Node/Java), and record evidence. | ||||||
|  | - Surface explainable diagnostics for unresolved branches (env indirection, missing files, unsupported syntax) and emit metrics. | ||||||
|  | - Package analyzers as signed plug-ins under `plugins/scanner/entrytrace/`, guarded by restart-only policy. | ||||||
|  |  | ||||||
|  | ## Out of Scope | ||||||
|  | - SBOM emission/diffing (owned by `Scanner.Emit`/`Scanner.Diff`). | ||||||
|  | - Runtime enforcement or live drift reconciliation (owned by Zastava). | ||||||
|  | - Registry/network fetchers beyond file lookups inside extracted layers. | ||||||
|  |  | ||||||
|  | ## Interfaces & Contracts | ||||||
|  | - Primary entry point: `IEntryTraceAnalyzer.ResolveAsync` returning a deterministic `EntryTraceGraph`. | ||||||
|  | - Graph nodes must include file path, line span, interpreter classification, evidence source, and follow `Scanner.Core` timestamp/ID helpers when emitting events. | ||||||
|  | - Diagnostics must enumerate unknown reasons from fixed enum; metrics tagged `entrytrace.*`. | ||||||
|  | - Plug-ins register via `IEntryTraceAnalyzerFactory` and must validate against `IPluginCatalogGuard`. | ||||||
|  |  | ||||||
|  | ## Observability & Security | ||||||
|  | - No dynamic assembly loading beyond restart-time plug-in catalog. | ||||||
|  | - Structured logs include `scanId`, `imageDigest`, `layerDigest`, `command`, `reason`. | ||||||
|  | - Metrics counters: `entrytrace_resolutions_total{result}`, `entrytrace_unresolved_total{reason}`. | ||||||
|  | - Deny `source` directives outside image root; sandbox file IO via provided `IRootFileSystem`. | ||||||
|  |  | ||||||
|  | ## Testing | ||||||
|  | - Unit tests live in `../StellaOps.Scanner.EntryTrace.Tests` with golden fixtures under `Fixtures/`. | ||||||
|  | - Determinism harness: same inputs produce byte-identical serialized graphs. | ||||||
|  | - Parser fuzz seeds captured for regression; interpreter tracers validated with sample scripts for Python, Node, Java launchers. | ||||||
| @@ -0,0 +1,51 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Diagnostics.Metrics; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace.Diagnostics; | ||||||
|  |  | ||||||
|  | public static class EntryTraceInstrumentation | ||||||
|  | { | ||||||
|  |     public static readonly Meter Meter = new("stellaops.scanner.entrytrace", "1.0.0"); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public sealed class EntryTraceMetrics | ||||||
|  | { | ||||||
|  |     private readonly Counter<long> _resolutions; | ||||||
|  |     private readonly Counter<long> _unresolved; | ||||||
|  |  | ||||||
|  |     public EntryTraceMetrics() | ||||||
|  |     { | ||||||
|  |         _resolutions = EntryTraceInstrumentation.Meter.CreateCounter<long>( | ||||||
|  |             "entrytrace_resolutions_total", | ||||||
|  |             description: "Number of entry trace attempts by outcome."); | ||||||
|  |         _unresolved = EntryTraceInstrumentation.Meter.CreateCounter<long>( | ||||||
|  |             "entrytrace_unresolved_total", | ||||||
|  |             description: "Number of unresolved entry trace hops by reason."); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void RecordOutcome(string imageDigest, string scanId, EntryTraceOutcome outcome) | ||||||
|  |     { | ||||||
|  |         _resolutions.Add(1, CreateTags(imageDigest, scanId, ("outcome", outcome.ToString().ToLowerInvariant()))); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void RecordUnknown(string imageDigest, string scanId, EntryTraceUnknownReason reason) | ||||||
|  |     { | ||||||
|  |         _unresolved.Add(1, CreateTags(imageDigest, scanId, ("reason", reason.ToString().ToLowerInvariant()))); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static KeyValuePair<string, object?>[] CreateTags(string imageDigest, string scanId, params (string Key, object? Value)[] extras) | ||||||
|  |     { | ||||||
|  |         var tags = new List<KeyValuePair<string, object?>>(2 + extras.Length) | ||||||
|  |         { | ||||||
|  |             new("image", imageDigest), | ||||||
|  |             new("scan.id", scanId) | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         foreach (var extra in extras) | ||||||
|  |         { | ||||||
|  |             tags.Add(new KeyValuePair<string, object?>(extra.Key, extra.Value)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return tags.ToArray(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										963
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										963
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,963 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Collections.Immutable; | ||||||
|  | using System.IO; | ||||||
|  | using System.Linq; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Scanner.EntryTrace.Diagnostics; | ||||||
|  | using StellaOps.Scanner.EntryTrace.Parsing; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer | ||||||
|  | { | ||||||
|  |     private readonly EntryTraceAnalyzerOptions _options; | ||||||
|  |     private readonly EntryTraceMetrics _metrics; | ||||||
|  |     private readonly ILogger<EntryTraceAnalyzer> _logger; | ||||||
|  |  | ||||||
|  |     public EntryTraceAnalyzer( | ||||||
|  |         IOptions<EntryTraceAnalyzerOptions> options, | ||||||
|  |         EntryTraceMetrics metrics, | ||||||
|  |         ILogger<EntryTraceAnalyzer> logger) | ||||||
|  |     { | ||||||
|  |         _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); | ||||||
|  |         _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); | ||||||
|  |         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|  |  | ||||||
|  |         if (_options.MaxDepth <= 0) | ||||||
|  |         { | ||||||
|  |             _options.MaxDepth = 32; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(_options.DefaultPath)) | ||||||
|  |         { | ||||||
|  |             _options.DefaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public ValueTask<EntryTraceGraph> ResolveAsync( | ||||||
|  |         EntrypointSpecification entrypoint, | ||||||
|  |         EntryTraceContext context, | ||||||
|  |         CancellationToken cancellationToken = default) | ||||||
|  |     { | ||||||
|  |         if (entrypoint is null) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentNullException(nameof(entrypoint)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (context is null) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentNullException(nameof(context)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |         var builder = new Builder( | ||||||
|  |             entrypoint, | ||||||
|  |             context, | ||||||
|  |             _options, | ||||||
|  |             _metrics, | ||||||
|  |             _logger); | ||||||
|  |  | ||||||
|  |         var graph = builder.BuildGraph(); | ||||||
|  |         _metrics.RecordOutcome(context.ImageDigest, context.ScanId, graph.Outcome); | ||||||
|  |         foreach (var diagnostic in graph.Diagnostics) | ||||||
|  |         { | ||||||
|  |             _metrics.RecordUnknown(context.ImageDigest, context.ScanId, diagnostic.Reason); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return ValueTask.FromResult(graph); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class Builder | ||||||
|  |     { | ||||||
|  |         private readonly EntrypointSpecification _entrypoint; | ||||||
|  |         private readonly EntryTraceContext _context; | ||||||
|  |         private readonly EntryTraceAnalyzerOptions _options; | ||||||
|  |         private readonly EntryTraceMetrics _metrics; | ||||||
|  |         private readonly ILogger _logger; | ||||||
|  |         private readonly ImmutableArray<string> _pathEntries; | ||||||
|  |         private readonly List<EntryTraceNode> _nodes = new(); | ||||||
|  |         private readonly List<EntryTraceEdge> _edges = new(); | ||||||
|  |         private readonly List<EntryTraceDiagnostic> _diagnostics = new(); | ||||||
|  |         private readonly HashSet<string> _visitedScripts = new(StringComparer.Ordinal); | ||||||
|  |         private readonly HashSet<string> _visitedCommands = new(StringComparer.Ordinal); | ||||||
|  |         private int _nextNodeId = 1; | ||||||
|  |  | ||||||
|  |         public Builder( | ||||||
|  |             EntrypointSpecification entrypoint, | ||||||
|  |             EntryTraceContext context, | ||||||
|  |             EntryTraceAnalyzerOptions options, | ||||||
|  |             EntryTraceMetrics metrics, | ||||||
|  |             ILogger logger) | ||||||
|  |         { | ||||||
|  |             _entrypoint = entrypoint; | ||||||
|  |             _context = context; | ||||||
|  |             _options = options; | ||||||
|  |             _metrics = metrics; | ||||||
|  |             _logger = logger; | ||||||
|  |             _pathEntries = DeterminePath(context); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private static ImmutableArray<string> DeterminePath(EntryTraceContext context) | ||||||
|  |         { | ||||||
|  |             if (context.Path.Length > 0) | ||||||
|  |             { | ||||||
|  |                 return context.Path; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (context.Environment.TryGetValue("PATH", out var raw) && !string.IsNullOrWhiteSpace(raw)) | ||||||
|  |             { | ||||||
|  |                 return raw.Split(':').Select(p => p.Trim()).Where(p => p.Length > 0).ToImmutableArray(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return ImmutableArray<string>.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public EntryTraceGraph BuildGraph() | ||||||
|  |         { | ||||||
|  |             var initialArgs = ComposeInitialCommand(_entrypoint); | ||||||
|  |             if (initialArgs.Length == 0) | ||||||
|  |             { | ||||||
|  |                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                     EntryTraceDiagnosticSeverity.Error, | ||||||
|  |                     EntryTraceUnknownReason.CommandNotFound, | ||||||
|  |                     "ENTRYPOINT/CMD yielded no executable command.", | ||||||
|  |                     Span: null, | ||||||
|  |                     RelatedPath: null)); | ||||||
|  |                 return ToGraph(EntryTraceOutcome.Unresolved); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             ResolveCommand(initialArgs, parent: null, originSpan: null, depth: 0, relationship: "entrypoint"); | ||||||
|  |  | ||||||
|  |             var outcome = DetermineOutcome(); | ||||||
|  |             return ToGraph(outcome); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private EntryTraceOutcome DetermineOutcome() | ||||||
|  |         { | ||||||
|  |             if (_diagnostics.Count == 0) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceOutcome.Resolved; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Error) | ||||||
|  |                 ? EntryTraceOutcome.Unresolved | ||||||
|  |                 : EntryTraceOutcome.PartiallyResolved; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private EntryTraceGraph ToGraph(EntryTraceOutcome outcome) | ||||||
|  |         { | ||||||
|  |             return new EntryTraceGraph( | ||||||
|  |                 outcome, | ||||||
|  |                 _nodes.ToImmutableArray(), | ||||||
|  |                 _edges.ToImmutableArray(), | ||||||
|  |                 _diagnostics.ToImmutableArray()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private ImmutableArray<string> ComposeInitialCommand(EntrypointSpecification specification) | ||||||
|  |         { | ||||||
|  |             if (specification.Entrypoint.Length > 0) | ||||||
|  |             { | ||||||
|  |                 if (specification.Command.Length > 0) | ||||||
|  |                 { | ||||||
|  |                     return specification.Entrypoint.Concat(specification.Command).ToImmutableArray(); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 return specification.Entrypoint; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (specification.Command.Length > 0) | ||||||
|  |             { | ||||||
|  |                 return specification.Command; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(specification.EntrypointShell)) | ||||||
|  |             { | ||||||
|  |                 return ImmutableArray.Create("/bin/sh", "-c", specification.EntrypointShell!); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(specification.CommandShell)) | ||||||
|  |             { | ||||||
|  |                 return ImmutableArray.Create("/bin/sh", "-c", specification.CommandShell!); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return ImmutableArray<string>.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private void ResolveCommand( | ||||||
|  |             ImmutableArray<string> arguments, | ||||||
|  |             EntryTraceNode? parent, | ||||||
|  |             EntryTraceSpan? originSpan, | ||||||
|  |             int depth, | ||||||
|  |             string relationship) | ||||||
|  |         { | ||||||
|  |             if (arguments.Length == 0) | ||||||
|  |             { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (depth >= _options.MaxDepth) | ||||||
|  |             { | ||||||
|  |                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                     EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                     EntryTraceUnknownReason.RecursionLimitReached, | ||||||
|  |                     $"Recursion depth limit {_options.MaxDepth} reached while resolving '{arguments[0]}'.", | ||||||
|  |                     originSpan, | ||||||
|  |                     RelatedPath: null)); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var commandName = arguments[0]; | ||||||
|  |             var evidence = default(EntryTraceEvidence?); | ||||||
|  |             var descriptor = default(RootFileDescriptor); | ||||||
|  |  | ||||||
|  |             if (!TryResolveExecutable(commandName, out descriptor, out evidence)) | ||||||
|  |             { | ||||||
|  |                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                     EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                     EntryTraceUnknownReason.CommandNotFound, | ||||||
|  |                     $"Command '{commandName}' not found in PATH.", | ||||||
|  |                     originSpan, | ||||||
|  |                     RelatedPath: null)); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var node = AddNode( | ||||||
|  |                 EntryTraceNodeKind.Command, | ||||||
|  |                 commandName, | ||||||
|  |                 arguments, | ||||||
|  |                 DetermineInterpreterKind(descriptor), | ||||||
|  |                 evidence, | ||||||
|  |                 originSpan); | ||||||
|  |  | ||||||
|  |             if (parent is not null) | ||||||
|  |             { | ||||||
|  |                 _edges.Add(new EntryTraceEdge(parent.Id, node.Id, relationship, Metadata: null)); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!_visitedCommands.Add(descriptor.Path)) | ||||||
|  |             { | ||||||
|  |                 // Prevent infinite loops when scripts call themselves recursively. | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (TryFollowInterpreter(node, descriptor, arguments, depth)) | ||||||
|  |             { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (TryFollowShell(node, descriptor, arguments, depth)) | ||||||
|  |             { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Terminal executable. | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool TryResolveExecutable( | ||||||
|  |             string commandName, | ||||||
|  |             out RootFileDescriptor descriptor, | ||||||
|  |             out EntryTraceEvidence? evidence) | ||||||
|  |         { | ||||||
|  |             evidence = null; | ||||||
|  |  | ||||||
|  |             if (commandName.Contains('/', StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 if (_context.FileSystem.TryReadAllText(commandName, out descriptor, out _)) | ||||||
|  |                 { | ||||||
|  |                     evidence = new EntryTraceEvidence(commandName, descriptor.LayerDigest, "path", null); | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (_context.FileSystem.TryResolveExecutable(commandName, Array.Empty<string>(), out descriptor)) | ||||||
|  |                 { | ||||||
|  |                     evidence = new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "path", null); | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (_context.FileSystem.TryResolveExecutable(commandName, _pathEntries, out descriptor)) | ||||||
|  |             { | ||||||
|  |                 evidence = new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "path-search", new Dictionary<string, string> | ||||||
|  |                 { | ||||||
|  |                     ["command"] = commandName | ||||||
|  |                 }); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             descriptor = null!; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool TryFollowInterpreter( | ||||||
|  |             EntryTraceNode node, | ||||||
|  |             RootFileDescriptor descriptor, | ||||||
|  |             ImmutableArray<string> arguments, | ||||||
|  |             int depth) | ||||||
|  |         { | ||||||
|  |             var interpreter = DetermineInterpreterKind(descriptor); | ||||||
|  |             if (interpreter == EntryTraceInterpreterKind.None) | ||||||
|  |             { | ||||||
|  |                 interpreter = DetectInterpreterFromCommand(arguments); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (interpreter == EntryTraceInterpreterKind.None) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             switch (interpreter) | ||||||
|  |             { | ||||||
|  |                 case EntryTraceInterpreterKind.Python: | ||||||
|  |                     return HandlePython(node, arguments, descriptor, depth); | ||||||
|  |                 case EntryTraceInterpreterKind.Node: | ||||||
|  |                     return HandleNode(node, arguments, descriptor, depth); | ||||||
|  |                 case EntryTraceInterpreterKind.Java: | ||||||
|  |                     return HandleJava(node, arguments, descriptor, depth); | ||||||
|  |                 default: | ||||||
|  |                     return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private EntryTraceInterpreterKind DetermineInterpreterKind(RootFileDescriptor descriptor) | ||||||
|  |         { | ||||||
|  |             if (descriptor.ShebangInterpreter is null) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.None; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var shebang = descriptor.ShebangInterpreter.ToLowerInvariant(); | ||||||
|  |             if (shebang.Contains("python", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.Python; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (shebang.Contains("node", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.Node; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (shebang.Contains("java", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.Java; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (shebang.Contains("sh", StringComparison.Ordinal) || shebang.Contains("bash", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.None; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return EntryTraceInterpreterKind.None; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private EntryTraceInterpreterKind DetectInterpreterFromCommand(ImmutableArray<string> arguments) | ||||||
|  |         { | ||||||
|  |             if (arguments.Length == 0) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.None; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var command = arguments[0]; | ||||||
|  |             if (command.Equals("python", StringComparison.OrdinalIgnoreCase) || | ||||||
|  |                 command.StartsWith("python", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.Python; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (command.Equals("node", StringComparison.OrdinalIgnoreCase) || | ||||||
|  |                 command.Equals("nodejs", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.Node; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (command.Equals("java", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 return EntryTraceInterpreterKind.Java; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return EntryTraceInterpreterKind.None; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool HandlePython( | ||||||
|  |             EntryTraceNode node, | ||||||
|  |             ImmutableArray<string> arguments, | ||||||
|  |             RootFileDescriptor descriptor, | ||||||
|  |             int depth) | ||||||
|  |         { | ||||||
|  |             if (arguments.Length < 2) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var argIndex = 1; | ||||||
|  |             var moduleMode = false; | ||||||
|  |             string? moduleName = null; | ||||||
|  |             string? scriptPath = null; | ||||||
|  |  | ||||||
|  |             while (argIndex < arguments.Length) | ||||||
|  |             { | ||||||
|  |                 var current = arguments[argIndex]; | ||||||
|  |                 if (current == "-m" && argIndex + 1 < arguments.Length) | ||||||
|  |                 { | ||||||
|  |                     moduleMode = true; | ||||||
|  |                     moduleName = arguments[argIndex + 1]; | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!current.StartsWith("-", StringComparison.Ordinal)) | ||||||
|  |                 { | ||||||
|  |                     scriptPath = current; | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 argIndex++; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (moduleMode && moduleName is not null) | ||||||
|  |             { | ||||||
|  |                 _edges.Add(new EntryTraceEdge(node.Id, node.Id, "python-module", new Dictionary<string, string> | ||||||
|  |                 { | ||||||
|  |                     ["module"] = moduleName | ||||||
|  |                 })); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (scriptPath is null) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!_context.FileSystem.TryReadAllText(scriptPath, out var scriptDescriptor, out var content)) | ||||||
|  |             { | ||||||
|  |                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                     EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                     EntryTraceUnknownReason.MissingFile, | ||||||
|  |                     $"Python script '{scriptPath}' was not found.", | ||||||
|  |                     Span: null, | ||||||
|  |                     RelatedPath: scriptPath)); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var scriptNode = AddNode( | ||||||
|  |                 EntryTraceNodeKind.Script, | ||||||
|  |                 scriptPath, | ||||||
|  |                 ImmutableArray<string>.Empty, | ||||||
|  |                 EntryTraceInterpreterKind.Python, | ||||||
|  |                 new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||||
|  |                 null); | ||||||
|  |  | ||||||
|  |             _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||||
|  |  | ||||||
|  |             if (IsLikelyShell(content)) | ||||||
|  |             { | ||||||
|  |                 ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool HandleNode( | ||||||
|  |             EntryTraceNode node, | ||||||
|  |             ImmutableArray<string> arguments, | ||||||
|  |             RootFileDescriptor descriptor, | ||||||
|  |             int depth) | ||||||
|  |         { | ||||||
|  |             if (arguments.Length < 2) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var scriptArg = arguments.Skip(1).FirstOrDefault(a => !a.StartsWith("-", StringComparison.Ordinal)); | ||||||
|  |             if (string.IsNullOrWhiteSpace(scriptArg)) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!_context.FileSystem.TryReadAllText(scriptArg, out var scriptDescriptor, out var content)) | ||||||
|  |             { | ||||||
|  |                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                     EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                     EntryTraceUnknownReason.MissingFile, | ||||||
|  |                     $"Node script '{scriptArg}' was not found.", | ||||||
|  |                     Span: null, | ||||||
|  |                     RelatedPath: scriptArg)); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var scriptNode = AddNode( | ||||||
|  |                 EntryTraceNodeKind.Script, | ||||||
|  |                 scriptArg, | ||||||
|  |                 ImmutableArray<string>.Empty, | ||||||
|  |                 EntryTraceInterpreterKind.Node, | ||||||
|  |                 new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||||
|  |                 null); | ||||||
|  |  | ||||||
|  |             _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool HandleJava( | ||||||
|  |             EntryTraceNode node, | ||||||
|  |             ImmutableArray<string> arguments, | ||||||
|  |             RootFileDescriptor descriptor, | ||||||
|  |             int depth) | ||||||
|  |         { | ||||||
|  |             if (arguments.Length < 2) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             string? jar = null; | ||||||
|  |             string? mainClass = null; | ||||||
|  |  | ||||||
|  |             for (var i = 1; i < arguments.Length; i++) | ||||||
|  |             { | ||||||
|  |                 var arg = arguments[i]; | ||||||
|  |                 if (arg == "-jar" && i + 1 < arguments.Length) | ||||||
|  |                 { | ||||||
|  |                     jar = arguments[i + 1]; | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!arg.StartsWith("-", StringComparison.Ordinal) && mainClass is null) | ||||||
|  |                 { | ||||||
|  |                     mainClass = arg; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (jar is not null) | ||||||
|  |             { | ||||||
|  |                 if (!_context.FileSystem.TryResolveExecutable(jar, Array.Empty<string>(), out var jarDescriptor)) | ||||||
|  |                 { | ||||||
|  |                     _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                         EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                         EntryTraceUnknownReason.JarNotFound, | ||||||
|  |                         $"Java JAR '{jar}' not found.", | ||||||
|  |                         Span: null, | ||||||
|  |                         RelatedPath: jar)); | ||||||
|  |                 } | ||||||
|  |                 else | ||||||
|  |                 { | ||||||
|  |                     var jarNode = AddNode( | ||||||
|  |                         EntryTraceNodeKind.Executable, | ||||||
|  |                         jarDescriptor.Path, | ||||||
|  |                         ImmutableArray<string>.Empty, | ||||||
|  |                         EntryTraceInterpreterKind.Java, | ||||||
|  |                         new EntryTraceEvidence(jarDescriptor.Path, jarDescriptor.LayerDigest, "jar", null), | ||||||
|  |                         null); | ||||||
|  |                     _edges.Add(new EntryTraceEdge(node.Id, jarNode.Id, "executes", null)); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (mainClass is not null) | ||||||
|  |             { | ||||||
|  |                 _edges.Add(new EntryTraceEdge(node.Id, node.Id, "java-main", new Dictionary<string, string> | ||||||
|  |                 { | ||||||
|  |                     ["class"] = mainClass | ||||||
|  |                 })); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool TryFollowShell( | ||||||
|  |             EntryTraceNode node, | ||||||
|  |             RootFileDescriptor descriptor, | ||||||
|  |             ImmutableArray<string> arguments, | ||||||
|  |             int depth) | ||||||
|  |         { | ||||||
|  |             if (!IsShellExecutable(descriptor, arguments)) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (arguments.Length >= 2 && arguments[1] == "-c" && arguments.Length >= 3) | ||||||
|  |             { | ||||||
|  |                 var scriptText = arguments[2]; | ||||||
|  |                 ResolveShellScript(scriptText, descriptor.Path, node, depth + 1); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (arguments.Length >= 2) | ||||||
|  |             { | ||||||
|  |                 var candidate = arguments[1]; | ||||||
|  |                 if (_context.FileSystem.TryReadAllText(candidate, out var scriptDescriptor, out var content)) | ||||||
|  |                 { | ||||||
|  |                     var scriptNode = AddNode( | ||||||
|  |                         EntryTraceNodeKind.Script, | ||||||
|  |                         candidate, | ||||||
|  |                         ImmutableArray<string>.Empty, | ||||||
|  |                         EntryTraceInterpreterKind.None, | ||||||
|  |                         new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||||
|  |                         null); | ||||||
|  |                     _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||||
|  |                     ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (arguments.Length == 1) | ||||||
|  |             { | ||||||
|  |                 if (_context.FileSystem.TryReadAllText(descriptor.Path, out var scriptDescriptor, out var content)) | ||||||
|  |                 { | ||||||
|  |                     var scriptNode = AddNode( | ||||||
|  |                         EntryTraceNodeKind.Script, | ||||||
|  |                         descriptor.Path, | ||||||
|  |                         ImmutableArray<string>.Empty, | ||||||
|  |                         EntryTraceInterpreterKind.None, | ||||||
|  |                         new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||||
|  |                         null); | ||||||
|  |                     _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||||
|  |                     ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private static bool IsShellExecutable(RootFileDescriptor descriptor, ImmutableArray<string> arguments) | ||||||
|  |         { | ||||||
|  |             if (descriptor.ShebangInterpreter is not null && | ||||||
|  |                 (descriptor.ShebangInterpreter.Contains("sh", StringComparison.OrdinalIgnoreCase) || | ||||||
|  |                  descriptor.ShebangInterpreter.Contains("bash", StringComparison.OrdinalIgnoreCase))) | ||||||
|  |             { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var command = arguments[0]; | ||||||
|  |             return command is "/bin/sh" or "sh" or "bash" or "/bin/bash"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private void ResolveShellScript( | ||||||
|  |             string scriptContent, | ||||||
|  |             string scriptPath, | ||||||
|  |             EntryTraceNode parent, | ||||||
|  |             int depth) | ||||||
|  |         { | ||||||
|  |             if (_visitedScripts.Contains(scriptPath)) | ||||||
|  |             { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             _visitedScripts.Add(scriptPath); | ||||||
|  |  | ||||||
|  |             ShellScript ast; | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 ast = ShellParser.Parse(scriptContent); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                     EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                     EntryTraceUnknownReason.UnsupportedSyntax, | ||||||
|  |                     $"Failed to parse shell script '{scriptPath}': {ex.Message}", | ||||||
|  |                     Span: null, | ||||||
|  |                     RelatedPath: scriptPath)); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             foreach (var node in ast.Nodes) | ||||||
|  |             { | ||||||
|  |                 HandleShellNode(node, parent, scriptPath, depth); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private void HandleShellNode( | ||||||
|  |             ShellNode node, | ||||||
|  |             EntryTraceNode parent, | ||||||
|  |             string scriptPath, | ||||||
|  |             int depth) | ||||||
|  |         { | ||||||
|  |             switch (node) | ||||||
|  |             { | ||||||
|  |                 case ShellExecNode execNode: | ||||||
|  |                     { | ||||||
|  |                         var args = MaterializeArguments(execNode.Arguments); | ||||||
|  |                         if (args.Length <= 1) | ||||||
|  |                         { | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         var execArgs = args.RemoveAt(0); | ||||||
|  |                         ResolveCommand(execArgs, parent, ToEntryTraceSpan(execNode.Span, scriptPath), depth + 1, "executes"); | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 case ShellIncludeNode includeNode: | ||||||
|  |                     { | ||||||
|  |                         var includeArg = includeNode.PathExpression; | ||||||
|  |                         var includePath = ResolveScriptPath(scriptPath, includeArg); | ||||||
|  |                         if (!_context.FileSystem.TryReadAllText(includePath, out var descriptor, out var content)) | ||||||
|  |                         { | ||||||
|  |                             _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                                 EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                                 EntryTraceUnknownReason.MissingFile, | ||||||
|  |                                 $"Included script '{includePath}' not found.", | ||||||
|  |                                 ToEntryTraceSpan(includeNode.Span, scriptPath), | ||||||
|  |                                 includePath)); | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         var includeTraceNode = AddNode( | ||||||
|  |                             EntryTraceNodeKind.Include, | ||||||
|  |                             includePath, | ||||||
|  |                             ImmutableArray<string>.Empty, | ||||||
|  |                             EntryTraceInterpreterKind.None, | ||||||
|  |                             new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "include", null), | ||||||
|  |                             ToEntryTraceSpan(includeNode.Span, scriptPath)); | ||||||
|  |  | ||||||
|  |                         _edges.Add(new EntryTraceEdge(parent.Id, includeTraceNode.Id, "includes", null)); | ||||||
|  |                         ResolveShellScript(content, descriptor.Path, includeTraceNode, depth + 1); | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 case ShellRunPartsNode runPartsNode when _options.FollowRunParts: | ||||||
|  |                     { | ||||||
|  |                         var directory = ResolveScriptPath(scriptPath, runPartsNode.DirectoryExpression); | ||||||
|  |                         if (!_context.FileSystem.DirectoryExists(directory)) | ||||||
|  |                         { | ||||||
|  |                             _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                                 EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                                 EntryTraceUnknownReason.MissingFile, | ||||||
|  |                                 $"run-parts directory '{directory}' not found.", | ||||||
|  |                                 ToEntryTraceSpan(runPartsNode.Span, scriptPath), | ||||||
|  |                                 directory)); | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         var entries = _context.FileSystem.EnumerateDirectory(directory) | ||||||
|  |                             .Where(e => !e.IsDirectory && e.IsExecutable) | ||||||
|  |                             .OrderBy(e => e.Path, StringComparer.Ordinal) | ||||||
|  |                             .Take(_options.RunPartsLimit) | ||||||
|  |                             .ToList(); | ||||||
|  |  | ||||||
|  |                         if (entries.Count == 0) | ||||||
|  |                         { | ||||||
|  |                             _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                                 EntryTraceDiagnosticSeverity.Info, | ||||||
|  |                                 EntryTraceUnknownReason.RunPartsEmpty, | ||||||
|  |                                 $"run-parts directory '{directory}' contained no executable files.", | ||||||
|  |                                 ToEntryTraceSpan(runPartsNode.Span, scriptPath), | ||||||
|  |                                 directory)); | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         var dirNode = AddNode( | ||||||
|  |                             EntryTraceNodeKind.RunPartsDirectory, | ||||||
|  |                             directory, | ||||||
|  |                             ImmutableArray<string>.Empty, | ||||||
|  |                             EntryTraceInterpreterKind.None, | ||||||
|  |                             new EntryTraceEvidence(directory, null, "run-parts", null), | ||||||
|  |                             ToEntryTraceSpan(runPartsNode.Span, scriptPath)); | ||||||
|  |                         _edges.Add(new EntryTraceEdge(parent.Id, dirNode.Id, "run-parts", null)); | ||||||
|  |  | ||||||
|  |                         foreach (var entry in entries) | ||||||
|  |                         { | ||||||
|  |                         var childNode = AddNode( | ||||||
|  |                             EntryTraceNodeKind.RunPartsScript, | ||||||
|  |                             entry.Path, | ||||||
|  |                             ImmutableArray<string>.Empty, | ||||||
|  |                             EntryTraceInterpreterKind.None, | ||||||
|  |                             new EntryTraceEvidence(entry.Path, entry.LayerDigest, "run-parts", null), | ||||||
|  |                             null); | ||||||
|  |                             _edges.Add(new EntryTraceEdge(dirNode.Id, childNode.Id, "executes", null)); | ||||||
|  |  | ||||||
|  |                             if (_context.FileSystem.TryReadAllText(entry.Path, out var childDescriptor, out var content)) | ||||||
|  |                             { | ||||||
|  |                                 ResolveShellScript(content, childDescriptor.Path, childNode, depth + 1); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 case ShellIfNode ifNode: | ||||||
|  |                     { | ||||||
|  |                         foreach (var branch in ifNode.Branches) | ||||||
|  |                         { | ||||||
|  |                             foreach (var inner in branch.Body) | ||||||
|  |                             { | ||||||
|  |                                 HandleShellNode(inner, parent, scriptPath, depth + 1); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 case ShellCaseNode caseNode: | ||||||
|  |                     { | ||||||
|  |                         foreach (var arm in caseNode.Arms) | ||||||
|  |                         { | ||||||
|  |                             foreach (var inner in arm.Body) | ||||||
|  |                             { | ||||||
|  |                                 HandleShellNode(inner, parent, scriptPath, depth + 1); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 case ShellCommandNode commandNode: | ||||||
|  |                     { | ||||||
|  |                         var args = MaterializeArguments(commandNode.Arguments); | ||||||
|  |                         if (args.Length == 0) | ||||||
|  |                         { | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         // Skip shell built-in wrappers. | ||||||
|  |                         if (args[0] is "command" or "env") | ||||||
|  |                         { | ||||||
|  |                             var sliced = args.Skip(1).ToImmutableArray(); | ||||||
|  |                             ResolveCommand(sliced, parent, ToEntryTraceSpan(commandNode.Span, scriptPath), depth + 1, "calls"); | ||||||
|  |                         } | ||||||
|  |                         else | ||||||
|  |                         { | ||||||
|  |                             ResolveCommand(args, parent, ToEntryTraceSpan(commandNode.Span, scriptPath), depth + 1, "calls"); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 default: | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private static EntryTraceSpan? ToEntryTraceSpan(ShellSpan span, string path) | ||||||
|  |             => new(path, span.StartLine, span.StartColumn, span.EndLine, span.EndColumn); | ||||||
|  |  | ||||||
|  |         private static ImmutableArray<string> MaterializeArguments(ImmutableArray<ShellToken> tokens) | ||||||
|  |         { | ||||||
|  |             var builder = ImmutableArray.CreateBuilder<string>(tokens.Length); | ||||||
|  |             foreach (var token in tokens) | ||||||
|  |             { | ||||||
|  |                 builder.Add(token.Value); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return builder.ToImmutable(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private string ResolveScriptPath(string currentScript, string candidate) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(candidate)) | ||||||
|  |             { | ||||||
|  |                 return candidate; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (candidate.StartsWith("/", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 return NormalizeUnixPath(candidate); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (candidate.StartsWith("$", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||||
|  |                     EntryTraceDiagnosticSeverity.Warning, | ||||||
|  |                     EntryTraceUnknownReason.DynamicEnvironmentReference, | ||||||
|  |                     $"Path '{candidate}' depends on environment variable expansion and cannot be resolved statically.", | ||||||
|  |                     Span: null, | ||||||
|  |                     RelatedPath: candidate)); | ||||||
|  |                 return candidate; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var normalizedScript = NormalizeUnixPath(currentScript); | ||||||
|  |             var lastSlash = normalizedScript.LastIndexOf('/'); | ||||||
|  |             var baseDirectory = lastSlash <= 0 ? "/" : normalizedScript[..lastSlash]; | ||||||
|  |             return CombineUnixPath(baseDirectory, candidate); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private static bool IsLikelyShell(string content) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrEmpty(content)) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (content.StartsWith("#!", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 return content.Contains("sh", StringComparison.OrdinalIgnoreCase); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return content.Contains("#!/bin/sh", StringComparison.Ordinal); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private EntryTraceNode AddNode( | ||||||
|  |             EntryTraceNodeKind kind, | ||||||
|  |             string displayName, | ||||||
|  |             ImmutableArray<string> arguments, | ||||||
|  |             EntryTraceInterpreterKind interpreterKind, | ||||||
|  |             EntryTraceEvidence? evidence, | ||||||
|  |             EntryTraceSpan? span) | ||||||
|  |         { | ||||||
|  |             var node = new EntryTraceNode( | ||||||
|  |                 _nextNodeId++, | ||||||
|  |                 kind, | ||||||
|  |                 displayName, | ||||||
|  |                 arguments, | ||||||
|  |                 interpreterKind, | ||||||
|  |                 evidence, | ||||||
|  |                 span); | ||||||
|  |             _nodes.Add(node); | ||||||
|  |             return node; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private static string CombineUnixPath(string baseDirectory, string relative) | ||||||
|  |         { | ||||||
|  |             var normalizedBase = NormalizeUnixPath(baseDirectory); | ||||||
|  |             var trimmedRelative = relative.Replace('\\', '/').Trim(); | ||||||
|  |             if (string.IsNullOrEmpty(trimmedRelative)) | ||||||
|  |             { | ||||||
|  |                 return normalizedBase; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (trimmedRelative.StartsWith('/')) | ||||||
|  |             { | ||||||
|  |                 return NormalizeUnixPath(trimmedRelative); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!normalizedBase.EndsWith('/')) | ||||||
|  |             { | ||||||
|  |                 normalizedBase += "/"; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return NormalizeUnixPath(normalizedBase + trimmedRelative); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private static string NormalizeUnixPath(string path) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(path)) | ||||||
|  |             { | ||||||
|  |                 return "/"; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var text = path.Replace('\\', '/').Trim(); | ||||||
|  |             if (!text.StartsWith('/')) | ||||||
|  |             { | ||||||
|  |                 text = "/" + text; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var segments = new List<string>(); | ||||||
|  |             foreach (var part in text.Split('/', StringSplitOptions.RemoveEmptyEntries)) | ||||||
|  |             { | ||||||
|  |                 if (part == ".") | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (part == "..") | ||||||
|  |                 { | ||||||
|  |                     if (segments.Count > 0) | ||||||
|  |                     { | ||||||
|  |                         segments.RemoveAt(segments.Count - 1); | ||||||
|  |                     } | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 segments.Add(part); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return segments.Count == 0 ? "/" : "/" + string.Join('/', segments); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,26 @@ | |||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | public sealed class EntryTraceAnalyzerOptions | ||||||
|  | { | ||||||
|  |     public const string SectionName = "Scanner:Analyzers:EntryTrace"; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Maximum recursion depth while following includes/run-parts/interpreters. | ||||||
|  |     /// </summary> | ||||||
|  |     public int MaxDepth { get; set; } = 64; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Enables traversal of run-parts directories. | ||||||
|  |     /// </summary> | ||||||
|  |     public bool FollowRunParts { get; set; } = true; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Colon-separated default PATH string used when the environment omits PATH. | ||||||
|  |     /// </summary> | ||||||
|  |     public string DefaultPath { get; set; } = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Maximum number of scripts considered per run-parts directory to prevent explosion. | ||||||
|  |     /// </summary> | ||||||
|  |     public int RunPartsLimit { get; set; } = 64; | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Provides runtime context for entry trace analysis. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record EntryTraceContext( | ||||||
|  |     IRootFileSystem FileSystem, | ||||||
|  |     ImmutableDictionary<string, string> Environment, | ||||||
|  |     ImmutableArray<string> Path, | ||||||
|  |     string WorkingDirectory, | ||||||
|  |     string ImageDigest, | ||||||
|  |     string ScanId, | ||||||
|  |     ILogger? Logger); | ||||||
							
								
								
									
										125
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceTypes.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceTypes.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Collections.Immutable; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Outcome classification for entrypoint resolution attempts. | ||||||
|  | /// </summary> | ||||||
|  | public enum EntryTraceOutcome | ||||||
|  | { | ||||||
|  |     Resolved, | ||||||
|  |     PartiallyResolved, | ||||||
|  |     Unresolved | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Logical classification for nodes in the entry trace graph. | ||||||
|  | /// </summary> | ||||||
|  | public enum EntryTraceNodeKind | ||||||
|  | { | ||||||
|  |     Command, | ||||||
|  |     Script, | ||||||
|  |     Include, | ||||||
|  |     Interpreter, | ||||||
|  |     Executable, | ||||||
|  |     RunPartsDirectory, | ||||||
|  |     RunPartsScript | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Interpreter categories supported by the analyzer. | ||||||
|  | /// </summary> | ||||||
|  | public enum EntryTraceInterpreterKind | ||||||
|  | { | ||||||
|  |     None, | ||||||
|  |     Python, | ||||||
|  |     Node, | ||||||
|  |     Java | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Diagnostic severity levels emitted by the analyzer. | ||||||
|  | /// </summary> | ||||||
|  | public enum EntryTraceDiagnosticSeverity | ||||||
|  | { | ||||||
|  |     Info, | ||||||
|  |     Warning, | ||||||
|  |     Error | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Enumerates the canonical reasons for unresolved edges. | ||||||
|  | /// </summary> | ||||||
|  | public enum EntryTraceUnknownReason | ||||||
|  | { | ||||||
|  |     CommandNotFound, | ||||||
|  |     MissingFile, | ||||||
|  |     DynamicEnvironmentReference, | ||||||
|  |     UnsupportedSyntax, | ||||||
|  |     RecursionLimitReached, | ||||||
|  |     InterpreterNotSupported, | ||||||
|  |     ModuleNotFound, | ||||||
|  |     JarNotFound, | ||||||
|  |     RunPartsEmpty, | ||||||
|  |     PermissionDenied | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Represents a span within a script file. | ||||||
|  | /// </summary> | ||||||
|  | public readonly record struct EntryTraceSpan( | ||||||
|  |     string? Path, | ||||||
|  |     int StartLine, | ||||||
|  |     int StartColumn, | ||||||
|  |     int EndLine, | ||||||
|  |     int EndColumn); | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Evidence describing where a node originated from within the image. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record EntryTraceEvidence( | ||||||
|  |     string Path, | ||||||
|  |     string? LayerDigest, | ||||||
|  |     string Source, | ||||||
|  |     IReadOnlyDictionary<string, string>? Metadata); | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Represents a node in the entry trace graph. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record EntryTraceNode( | ||||||
|  |     int Id, | ||||||
|  |     EntryTraceNodeKind Kind, | ||||||
|  |     string DisplayName, | ||||||
|  |     ImmutableArray<string> Arguments, | ||||||
|  |     EntryTraceInterpreterKind InterpreterKind, | ||||||
|  |     EntryTraceEvidence? Evidence, | ||||||
|  |     EntryTraceSpan? Span); | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Represents a directed edge in the entry trace graph. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record EntryTraceEdge( | ||||||
|  |     int FromNodeId, | ||||||
|  |     int ToNodeId, | ||||||
|  |     string Relationship, | ||||||
|  |     IReadOnlyDictionary<string, string>? Metadata); | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Captures diagnostic information regarding resolution gaps. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record EntryTraceDiagnostic( | ||||||
|  |     EntryTraceDiagnosticSeverity Severity, | ||||||
|  |     EntryTraceUnknownReason Reason, | ||||||
|  |     string Message, | ||||||
|  |     EntryTraceSpan? Span, | ||||||
|  |     string? RelatedPath); | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Final graph output produced by the analyzer. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record EntryTraceGraph( | ||||||
|  |     EntryTraceOutcome Outcome, | ||||||
|  |     ImmutableArray<EntryTraceNode> Nodes, | ||||||
|  |     ImmutableArray<EntryTraceEdge> Edges, | ||||||
|  |     ImmutableArray<EntryTraceDiagnostic> Diagnostics); | ||||||
							
								
								
									
										71
									
								
								src/StellaOps.Scanner.EntryTrace/EntrypointSpecification.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/StellaOps.Scanner.EntryTrace/EntrypointSpecification.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Represents the combined Docker ENTRYPOINT/CMD contract provided to the analyzer. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record EntrypointSpecification | ||||||
|  | { | ||||||
|  |     private EntrypointSpecification( | ||||||
|  |         ImmutableArray<string> entrypoint, | ||||||
|  |         ImmutableArray<string> command, | ||||||
|  |         string? entrypointShell, | ||||||
|  |         string? commandShell) | ||||||
|  |     { | ||||||
|  |         Entrypoint = entrypoint; | ||||||
|  |         Command = command; | ||||||
|  |         EntrypointShell = string.IsNullOrWhiteSpace(entrypointShell) ? null : entrypointShell; | ||||||
|  |         CommandShell = string.IsNullOrWhiteSpace(commandShell) ? null : commandShell; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Exec-form ENTRYPOINT arguments. | ||||||
|  |     /// </summary> | ||||||
|  |     public ImmutableArray<string> Entrypoint { get; } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Exec-form CMD arguments. | ||||||
|  |     /// </summary> | ||||||
|  |     public ImmutableArray<string> Command { get; } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Shell-form ENTRYPOINT (if provided). | ||||||
|  |     /// </summary> | ||||||
|  |     public string? EntrypointShell { get; } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Shell-form CMD (if provided). | ||||||
|  |     /// </summary> | ||||||
|  |     public string? CommandShell { get; } | ||||||
|  |  | ||||||
|  |     public static EntrypointSpecification FromExecForm( | ||||||
|  |         IEnumerable<string>? entrypoint, | ||||||
|  |         IEnumerable<string>? command) | ||||||
|  |         => new( | ||||||
|  |             entrypoint is null ? ImmutableArray<string>.Empty : entrypoint.ToImmutableArray(), | ||||||
|  |             command is null ? ImmutableArray<string>.Empty : command.ToImmutableArray(), | ||||||
|  |             entrypointShell: null, | ||||||
|  |             commandShell: null); | ||||||
|  |  | ||||||
|  |     public static EntrypointSpecification FromShellForm( | ||||||
|  |         string? entrypoint, | ||||||
|  |         string? command) | ||||||
|  |         => new( | ||||||
|  |             ImmutableArray<string>.Empty, | ||||||
|  |             ImmutableArray<string>.Empty, | ||||||
|  |             entrypoint, | ||||||
|  |             command); | ||||||
|  |  | ||||||
|  |     public EntrypointSpecification WithCommand(IEnumerable<string>? command) | ||||||
|  |         => new(Entrypoint, command?.ToImmutableArray() ?? ImmutableArray<string>.Empty, EntrypointShell, CommandShell); | ||||||
|  |  | ||||||
|  |     public EntrypointSpecification WithCommandShell(string? commandShell) | ||||||
|  |         => new(Entrypoint, Command, EntrypointShell, commandShell); | ||||||
|  |  | ||||||
|  |     public EntrypointSpecification WithEntrypoint(IEnumerable<string>? entrypoint) | ||||||
|  |         => new(entrypoint?.ToImmutableArray() ?? ImmutableArray<string>.Empty, Command, EntrypointShell, CommandShell); | ||||||
|  |  | ||||||
|  |     public EntrypointSpecification WithEntrypointShell(string? entrypointShell) | ||||||
|  |         => new(Entrypoint, Command, entrypointShell, CommandShell); | ||||||
|  | } | ||||||
| @@ -0,0 +1,39 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Represents a layered read-only filesystem snapshot built from container layers. | ||||||
|  | /// </summary> | ||||||
|  | public interface IRootFileSystem | ||||||
|  | { | ||||||
|  |     /// <summary> | ||||||
|  |     /// Attempts to resolve an executable by name using the provided PATH entries. | ||||||
|  |     /// </summary> | ||||||
|  |     bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Attempts to read the contents of a file as UTF-8 text. | ||||||
|  |     /// </summary> | ||||||
|  |     bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Returns descriptors for entries contained within a directory. | ||||||
|  |     /// </summary> | ||||||
|  |     ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Checks whether a directory exists. | ||||||
|  |     /// </summary> | ||||||
|  |     bool DirectoryExists(string path); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Describes a file discovered within the layered filesystem. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record RootFileDescriptor( | ||||||
|  |     string Path, | ||||||
|  |     string? LayerDigest, | ||||||
|  |     bool IsExecutable, | ||||||
|  |     bool IsDirectory, | ||||||
|  |     string? ShebangInterpreter); | ||||||
							
								
								
									
										9
									
								
								src/StellaOps.Scanner.EntryTrace/IEntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/StellaOps.Scanner.EntryTrace/IEntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | public interface IEntryTraceAnalyzer | ||||||
|  | { | ||||||
|  |     ValueTask<EntryTraceGraph> ResolveAsync( | ||||||
|  |         EntrypointSpecification entrypoint, | ||||||
|  |         EntryTraceContext context, | ||||||
|  |         CancellationToken cancellationToken = default); | ||||||
|  | } | ||||||
							
								
								
									
										54
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellNodes.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellNodes.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||||
|  |  | ||||||
|  | public abstract record ShellNode(ShellSpan Span); | ||||||
|  |  | ||||||
|  | public sealed record ShellScript(ImmutableArray<ShellNode> Nodes); | ||||||
|  |  | ||||||
|  | public sealed record ShellSpan(int StartLine, int StartColumn, int EndLine, int EndColumn); | ||||||
|  |  | ||||||
|  | public sealed record ShellCommandNode( | ||||||
|  |     string Command, | ||||||
|  |     ImmutableArray<ShellToken> Arguments, | ||||||
|  |     ShellSpan Span) : ShellNode(Span); | ||||||
|  |  | ||||||
|  | public sealed record ShellIncludeNode( | ||||||
|  |     string PathExpression, | ||||||
|  |     ImmutableArray<ShellToken> Arguments, | ||||||
|  |     ShellSpan Span) : ShellNode(Span); | ||||||
|  |  | ||||||
|  | public sealed record ShellExecNode( | ||||||
|  |     ImmutableArray<ShellToken> Arguments, | ||||||
|  |     ShellSpan Span) : ShellNode(Span); | ||||||
|  |  | ||||||
|  | public sealed record ShellIfNode( | ||||||
|  |     ImmutableArray<ShellConditionalBranch> Branches, | ||||||
|  |     ShellSpan Span) : ShellNode(Span); | ||||||
|  |  | ||||||
|  | public sealed record ShellConditionalBranch( | ||||||
|  |     ShellConditionalKind Kind, | ||||||
|  |     ImmutableArray<ShellNode> Body, | ||||||
|  |     ShellSpan Span, | ||||||
|  |     string? PredicateSummary); | ||||||
|  |  | ||||||
|  | public enum ShellConditionalKind | ||||||
|  | { | ||||||
|  |     If, | ||||||
|  |     Elif, | ||||||
|  |     Else | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public sealed record ShellCaseNode( | ||||||
|  |     ImmutableArray<ShellCaseArm> Arms, | ||||||
|  |     ShellSpan Span) : ShellNode(Span); | ||||||
|  |  | ||||||
|  | public sealed record ShellCaseArm( | ||||||
|  |     ImmutableArray<string> Patterns, | ||||||
|  |     ImmutableArray<ShellNode> Body, | ||||||
|  |     ShellSpan Span); | ||||||
|  |  | ||||||
|  | public sealed record ShellRunPartsNode( | ||||||
|  |     string DirectoryExpression, | ||||||
|  |     ImmutableArray<ShellToken> Arguments, | ||||||
|  |     ShellSpan Span) : ShellNode(Span); | ||||||
							
								
								
									
										485
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellParser.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										485
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellParser.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,485 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  | using System.Globalization; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Text; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Deterministic parser producing a lightweight AST for Bourne shell constructs needed by EntryTrace. | ||||||
|  | /// Supports: simple commands, exec, source/dot, run-parts, if/elif/else/fi, case/esac. | ||||||
|  | /// </summary> | ||||||
|  | public sealed class ShellParser | ||||||
|  | { | ||||||
|  |     private readonly IReadOnlyList<ShellToken> _tokens; | ||||||
|  |     private int _index; | ||||||
|  |  | ||||||
|  |     private ShellParser(IReadOnlyList<ShellToken> tokens) | ||||||
|  |     { | ||||||
|  |         _tokens = tokens; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static ShellScript Parse(string source) | ||||||
|  |     { | ||||||
|  |         var tokenizer = new ShellTokenizer(); | ||||||
|  |         var tokens = tokenizer.Tokenize(source); | ||||||
|  |         var parser = new ShellParser(tokens); | ||||||
|  |         var nodes = parser.ParseNodes(untilKeywords: null); | ||||||
|  |         return new ShellScript(nodes.ToImmutableArray()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private List<ShellNode> ParseNodes(HashSet<string>? untilKeywords) | ||||||
|  |     { | ||||||
|  |         var nodes = new List<ShellNode>(); | ||||||
|  |  | ||||||
|  |         while (true) | ||||||
|  |         { | ||||||
|  |             SkipNewLines(); | ||||||
|  |             var token = Peek(); | ||||||
|  |  | ||||||
|  |             if (token.Kind == ShellTokenKind.EndOfFile) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (token.Kind == ShellTokenKind.Word && untilKeywords is not null && untilKeywords.Contains(token.Value)) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             ShellNode? node = token.Kind switch | ||||||
|  |             { | ||||||
|  |                 ShellTokenKind.Word when token.Value == "if" => ParseIf(), | ||||||
|  |                 ShellTokenKind.Word when token.Value == "case" => ParseCase(), | ||||||
|  |                 _ => ParseCommandLike() | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             if (node is not null) | ||||||
|  |             { | ||||||
|  |                 nodes.Add(node); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             SkipCommandSeparators(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return nodes; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ShellNode ParseCommandLike() | ||||||
|  |     { | ||||||
|  |         var start = Peek(); | ||||||
|  |         var tokens = ReadUntilTerminator(); | ||||||
|  |  | ||||||
|  |         if (tokens.Count == 0) | ||||||
|  |         { | ||||||
|  |             return new ShellCommandNode(string.Empty, ImmutableArray<ShellToken>.Empty, CreateSpan(start, start)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var normalizedName = ExtractCommandName(tokens); | ||||||
|  |         var immutableTokens = tokens.ToImmutableArray(); | ||||||
|  |         var span = CreateSpan(tokens[0], tokens[^1]); | ||||||
|  |  | ||||||
|  |         return normalizedName switch | ||||||
|  |         { | ||||||
|  |             "exec" => new ShellExecNode(immutableTokens, span), | ||||||
|  |             "source" or "." => new ShellIncludeNode( | ||||||
|  |                 ExtractPrimaryArgument(immutableTokens), | ||||||
|  |                 immutableTokens, | ||||||
|  |                 span), | ||||||
|  |             "run-parts" => new ShellRunPartsNode( | ||||||
|  |                 ExtractPrimaryArgument(immutableTokens), | ||||||
|  |                 immutableTokens, | ||||||
|  |                 span), | ||||||
|  |             _ => new ShellCommandNode(normalizedName, immutableTokens, span) | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ShellIfNode ParseIf() | ||||||
|  |     { | ||||||
|  |         var start = Expect(ShellTokenKind.Word, "if"); | ||||||
|  |         var predicateTokens = ReadUntilKeyword("then"); | ||||||
|  |         Expect(ShellTokenKind.Word, "then"); | ||||||
|  |  | ||||||
|  |         var branches = new List<ShellConditionalBranch>(); | ||||||
|  |         var predicateSummary = JoinTokens(predicateTokens); | ||||||
|  |         var thenNodes = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||||
|  |         { | ||||||
|  |             "elif", | ||||||
|  |             "else", | ||||||
|  |             "fi" | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         branches.Add(new ShellConditionalBranch( | ||||||
|  |             ShellConditionalKind.If, | ||||||
|  |             thenNodes.ToImmutableArray(), | ||||||
|  |             CreateSpan(start, thenNodes.LastOrDefault()?.Span ?? CreateSpan(start, start)), | ||||||
|  |             predicateSummary)); | ||||||
|  |  | ||||||
|  |         while (true) | ||||||
|  |         { | ||||||
|  |             SkipNewLines(); | ||||||
|  |             var next = Peek(); | ||||||
|  |             if (next.Kind != ShellTokenKind.Word) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (next.Value == "elif") | ||||||
|  |             { | ||||||
|  |                 var elifStart = Advance(); | ||||||
|  |                 var elifPredicate = ReadUntilKeyword("then"); | ||||||
|  |                 Expect(ShellTokenKind.Word, "then"); | ||||||
|  |                 var elifBody = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||||
|  |                 { | ||||||
|  |                     "elif", | ||||||
|  |                     "else", | ||||||
|  |                     "fi" | ||||||
|  |                 }); | ||||||
|  |                 var span = elifBody.Count > 0 | ||||||
|  |                     ? CreateSpan(elifStart, elifBody[^1].Span) | ||||||
|  |                     : CreateSpan(elifStart, elifStart); | ||||||
|  |  | ||||||
|  |                 branches.Add(new ShellConditionalBranch( | ||||||
|  |                     ShellConditionalKind.Elif, | ||||||
|  |                     elifBody.ToImmutableArray(), | ||||||
|  |                     span, | ||||||
|  |                     JoinTokens(elifPredicate))); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (next.Value == "else") | ||||||
|  |             { | ||||||
|  |                 var elseStart = Advance(); | ||||||
|  |                 var elseBody = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||||
|  |                 { | ||||||
|  |                     "fi" | ||||||
|  |                 }); | ||||||
|  |                 branches.Add(new ShellConditionalBranch( | ||||||
|  |                     ShellConditionalKind.Else, | ||||||
|  |                     elseBody.ToImmutableArray(), | ||||||
|  |                     elseBody.Count > 0 ? CreateSpan(elseStart, elseBody[^1].Span) : CreateSpan(elseStart, elseStart), | ||||||
|  |                     null)); | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             break; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Expect(ShellTokenKind.Word, "fi"); | ||||||
|  |         var end = Previous(); | ||||||
|  |         return new ShellIfNode(branches.ToImmutableArray(), CreateSpan(start, end)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ShellCaseNode ParseCase() | ||||||
|  |     { | ||||||
|  |         var start = Expect(ShellTokenKind.Word, "case"); | ||||||
|  |         var selectorTokens = ReadUntilKeyword("in"); | ||||||
|  |         Expect(ShellTokenKind.Word, "in"); | ||||||
|  |  | ||||||
|  |         var arms = new List<ShellCaseArm>(); | ||||||
|  |         while (true) | ||||||
|  |         { | ||||||
|  |             SkipNewLines(); | ||||||
|  |             var token = Peek(); | ||||||
|  |             if (token.Kind == ShellTokenKind.Word && token.Value == "esac") | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (token.Kind == ShellTokenKind.EndOfFile) | ||||||
|  |             { | ||||||
|  |                 throw new FormatException("Unexpected end of file while parsing case arms."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var patterns = ReadPatterns(); | ||||||
|  |             Expect(ShellTokenKind.Operator, ")"); | ||||||
|  |  | ||||||
|  |             var body = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||||
|  |             { | ||||||
|  |                 ";;", | ||||||
|  |                 "esac" | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             ShellSpan span; | ||||||
|  |             if (body.Count > 0) | ||||||
|  |             { | ||||||
|  |                 span = CreateSpan(patterns.FirstToken ?? token, body[^1].Span); | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 span = CreateSpan(patterns.FirstToken ?? token, token); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             arms.Add(new ShellCaseArm( | ||||||
|  |                 patterns.Values.ToImmutableArray(), | ||||||
|  |                 body.ToImmutableArray(), | ||||||
|  |                 span)); | ||||||
|  |  | ||||||
|  |             SkipNewLines(); | ||||||
|  |             var separator = Peek(); | ||||||
|  |             if (separator.Kind == ShellTokenKind.Operator && separator.Value == ";;") | ||||||
|  |             { | ||||||
|  |                 Advance(); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (separator.Kind == ShellTokenKind.Word && separator.Value == "esac") | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Expect(ShellTokenKind.Word, "esac"); | ||||||
|  |         return new ShellCaseNode(arms.ToImmutableArray(), CreateSpan(start, Previous())); | ||||||
|  |  | ||||||
|  |         (List<string> Values, ShellToken? FirstToken) ReadPatterns() | ||||||
|  |         { | ||||||
|  |             var values = new List<string>(); | ||||||
|  |             ShellToken? first = null; | ||||||
|  |             var sb = new StringBuilder(); | ||||||
|  |  | ||||||
|  |             while (true) | ||||||
|  |             { | ||||||
|  |                 var current = Peek(); | ||||||
|  |                 if (current.Kind is ShellTokenKind.Operator && current.Value is ")" or "|") | ||||||
|  |                 { | ||||||
|  |                     if (sb.Length > 0) | ||||||
|  |                     { | ||||||
|  |                         values.Add(sb.ToString()); | ||||||
|  |                         sb.Clear(); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     if (current.Value == "|") | ||||||
|  |                     { | ||||||
|  |                         Advance(); | ||||||
|  |                         continue; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (current.Kind == ShellTokenKind.EndOfFile) | ||||||
|  |                 { | ||||||
|  |                     throw new FormatException("Unexpected EOF in case arm pattern."); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (first is null) | ||||||
|  |                 { | ||||||
|  |                     first = current; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 sb.Append(current.Value); | ||||||
|  |                 Advance(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (values.Count == 0 && sb.Length > 0) | ||||||
|  |             { | ||||||
|  |                 values.Add(sb.ToString()); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return (values, first); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private List<ShellToken> ReadUntilTerminator() | ||||||
|  |     { | ||||||
|  |         var tokens = new List<ShellToken>(); | ||||||
|  |         while (true) | ||||||
|  |         { | ||||||
|  |             var token = Peek(); | ||||||
|  |             if (token.Kind is ShellTokenKind.EndOfFile or ShellTokenKind.NewLine) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (token.Kind == ShellTokenKind.Operator && token.Value is ";" or "&&" or "||") | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             tokens.Add(Advance()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return tokens; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ImmutableArray<ShellToken> ReadUntilKeyword(string keyword) | ||||||
|  |     { | ||||||
|  |         var tokens = new List<ShellToken>(); | ||||||
|  |         while (true) | ||||||
|  |         { | ||||||
|  |             var token = Peek(); | ||||||
|  |             if (token.Kind == ShellTokenKind.EndOfFile) | ||||||
|  |             { | ||||||
|  |                 throw new FormatException($"Unexpected EOF while looking for keyword '{keyword}'."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (token.Kind == ShellTokenKind.Word && token.Value == keyword) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             tokens.Add(Advance()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return tokens.ToImmutableArray(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string ExtractCommandName(IReadOnlyList<ShellToken> tokens) | ||||||
|  |     { | ||||||
|  |         foreach (var token in tokens) | ||||||
|  |         { | ||||||
|  |             if (token.Kind is not ShellTokenKind.Word and not ShellTokenKind.SingleQuoted and not ShellTokenKind.DoubleQuoted) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (token.Value.Contains('=', StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 // Skip environment assignments e.g. FOO=bar exec /app | ||||||
|  |                 var eqIndex = token.Value.IndexOf('=', StringComparison.Ordinal); | ||||||
|  |                 if (eqIndex > 0 && token.Value[..eqIndex].All(IsIdentifierChar)) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return NormalizeCommandName(token.Value); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return string.Empty; | ||||||
|  |  | ||||||
|  |         static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string NormalizeCommandName(string value) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(value)) | ||||||
|  |         { | ||||||
|  |             return string.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return value switch | ||||||
|  |         { | ||||||
|  |             "." => ".", | ||||||
|  |             _ => value.Trim() | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void SkipCommandSeparators() | ||||||
|  |     { | ||||||
|  |         while (true) | ||||||
|  |         { | ||||||
|  |             var token = Peek(); | ||||||
|  |             if (token.Kind == ShellTokenKind.NewLine) | ||||||
|  |             { | ||||||
|  |                 Advance(); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (token.Kind == ShellTokenKind.Operator && (token.Value == ";" || token.Value == "&")) | ||||||
|  |             { | ||||||
|  |                 Advance(); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             break; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void SkipNewLines() | ||||||
|  |     { | ||||||
|  |         while (Peek().Kind == ShellTokenKind.NewLine) | ||||||
|  |         { | ||||||
|  |             Advance(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ShellToken Expect(ShellTokenKind kind, string? value = null) | ||||||
|  |     { | ||||||
|  |         var token = Peek(); | ||||||
|  |         if (token.Kind != kind || (value is not null && token.Value != value)) | ||||||
|  |         { | ||||||
|  |             throw new FormatException($"Unexpected token '{token.Value}' at line {token.Line}, expected {value ?? kind.ToString()}."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return Advance(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ShellToken Advance() | ||||||
|  |     { | ||||||
|  |         if (_index >= _tokens.Count) | ||||||
|  |         { | ||||||
|  |             return _tokens[^1]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return _tokens[_index++]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ShellToken Peek() | ||||||
|  |     { | ||||||
|  |         if (_index >= _tokens.Count) | ||||||
|  |         { | ||||||
|  |             return _tokens[^1]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return _tokens[_index]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private ShellToken Previous() | ||||||
|  |     { | ||||||
|  |         if (_index == 0) | ||||||
|  |         { | ||||||
|  |             return _tokens[0]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return _tokens[_index - 1]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static ShellSpan CreateSpan(ShellToken start, ShellToken end) | ||||||
|  |     { | ||||||
|  |         return new ShellSpan(start.Line, start.Column, end.Line, end.Column + end.Value.Length); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static ShellSpan CreateSpan(ShellToken start, ShellSpan end) | ||||||
|  |     { | ||||||
|  |         return new ShellSpan(start.Line, start.Column, end.EndLine, end.EndColumn); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string JoinTokens(IEnumerable<ShellToken> tokens) | ||||||
|  |     { | ||||||
|  |         var builder = new StringBuilder(); | ||||||
|  |         var first = true; | ||||||
|  |         foreach (var token in tokens) | ||||||
|  |         { | ||||||
|  |             if (!first) | ||||||
|  |             { | ||||||
|  |                 builder.Append(' '); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             builder.Append(token.Value); | ||||||
|  |             first = false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return builder.ToString(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string ExtractPrimaryArgument(ImmutableArray<ShellToken> tokens) | ||||||
|  |     { | ||||||
|  |         if (tokens.Length <= 1) | ||||||
|  |         { | ||||||
|  |             return string.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for (var i = 1; i < tokens.Length; i++) | ||||||
|  |         { | ||||||
|  |             var token = tokens[i]; | ||||||
|  |             if (token.Kind is ShellTokenKind.Word or ShellTokenKind.SingleQuoted or ShellTokenKind.DoubleQuoted) | ||||||
|  |             { | ||||||
|  |                 return token.Value; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return string.Empty; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellToken.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellToken.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Token produced by the shell lexer. | ||||||
|  | /// </summary> | ||||||
|  | public readonly record struct ShellToken(ShellTokenKind Kind, string Value, int Line, int Column); | ||||||
|  |  | ||||||
|  | public enum ShellTokenKind | ||||||
|  | { | ||||||
|  |     Word, | ||||||
|  |     SingleQuoted, | ||||||
|  |     DoubleQuoted, | ||||||
|  |     Operator, | ||||||
|  |     NewLine, | ||||||
|  |     EndOfFile | ||||||
|  | } | ||||||
							
								
								
									
										200
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellTokenizer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellTokenizer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | |||||||
|  | using System.Globalization; | ||||||
|  | using System.Text; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Lightweight Bourne shell tokenizer sufficient for ENTRYPOINT scripts. | ||||||
|  | /// Deterministic: emits tokens in source order without normalization. | ||||||
|  | /// </summary> | ||||||
|  | public sealed class ShellTokenizer | ||||||
|  | { | ||||||
|  |     public IReadOnlyList<ShellToken> Tokenize(string source) | ||||||
|  |     { | ||||||
|  |         if (source is null) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentNullException(nameof(source)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var tokens = new List<ShellToken>(); | ||||||
|  |         var line = 1; | ||||||
|  |         var column = 1; | ||||||
|  |         var index = 0; | ||||||
|  |  | ||||||
|  |         while (index < source.Length) | ||||||
|  |         { | ||||||
|  |             var ch = source[index]; | ||||||
|  |  | ||||||
|  |             if (ch == '\r') | ||||||
|  |             { | ||||||
|  |                 index++; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (ch == '\n') | ||||||
|  |             { | ||||||
|  |                 tokens.Add(new ShellToken(ShellTokenKind.NewLine, "\n", line, column)); | ||||||
|  |                 index++; | ||||||
|  |                 line++; | ||||||
|  |                 column = 1; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (char.IsWhiteSpace(ch)) | ||||||
|  |             { | ||||||
|  |                 index++; | ||||||
|  |                 column++; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (ch == '#') | ||||||
|  |             { | ||||||
|  |                 // Comment: skip until newline. | ||||||
|  |                 while (index < source.Length && source[index] != '\n') | ||||||
|  |                 { | ||||||
|  |                     index++; | ||||||
|  |                 } | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (IsOperatorStart(ch)) | ||||||
|  |             { | ||||||
|  |                 var opStartColumn = column; | ||||||
|  |                 var op = ConsumeOperator(source, ref index, ref column); | ||||||
|  |                 tokens.Add(new ShellToken(ShellTokenKind.Operator, op, line, opStartColumn)); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (ch == '\'') | ||||||
|  |             { | ||||||
|  |                 var (value, length) = ConsumeSingleQuoted(source, index + 1); | ||||||
|  |                 tokens.Add(new ShellToken(ShellTokenKind.SingleQuoted, value, line, column)); | ||||||
|  |                 index += length + 2; | ||||||
|  |                 column += length + 2; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (ch == '"') | ||||||
|  |             { | ||||||
|  |                 var (value, length) = ConsumeDoubleQuoted(source, index + 1); | ||||||
|  |                 tokens.Add(new ShellToken(ShellTokenKind.DoubleQuoted, value, line, column)); | ||||||
|  |                 index += length + 2; | ||||||
|  |                 column += length + 2; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var (word, consumed) = ConsumeWord(source, index); | ||||||
|  |             tokens.Add(new ShellToken(ShellTokenKind.Word, word, line, column)); | ||||||
|  |             index += consumed; | ||||||
|  |             column += consumed; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         tokens.Add(new ShellToken(ShellTokenKind.EndOfFile, string.Empty, line, column)); | ||||||
|  |         return tokens; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool IsOperatorStart(char ch) => ch switch | ||||||
|  |     { | ||||||
|  |         ';' or '&' or '|' or '(' or ')' => true, | ||||||
|  |         _ => false | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     private static string ConsumeOperator(string source, ref int index, ref int column) | ||||||
|  |     { | ||||||
|  |         var start = index; | ||||||
|  |         var ch = source[index]; | ||||||
|  |         index++; | ||||||
|  |         column++; | ||||||
|  |  | ||||||
|  |         if (index < source.Length) | ||||||
|  |         { | ||||||
|  |             var next = source[index]; | ||||||
|  |             if ((ch == '&' && next == '&') || | ||||||
|  |                 (ch == '|' && next == '|') || | ||||||
|  |                 (ch == ';' && next == ';')) | ||||||
|  |             { | ||||||
|  |                 index++; | ||||||
|  |                 column++; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return source[start..index]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static (string Value, int Length) ConsumeSingleQuoted(string source, int startIndex) | ||||||
|  |     { | ||||||
|  |         var end = startIndex; | ||||||
|  |         while (end < source.Length && source[end] != '\'') | ||||||
|  |         { | ||||||
|  |             end++; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (end >= source.Length) | ||||||
|  |         { | ||||||
|  |             throw new FormatException("Unterminated single-quoted string in entrypoint script."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return (source[startIndex..end], end - startIndex); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static (string Value, int Length) ConsumeDoubleQuoted(string source, int startIndex) | ||||||
|  |     { | ||||||
|  |         var builder = new StringBuilder(); | ||||||
|  |         var index = startIndex; | ||||||
|  |  | ||||||
|  |         while (index < source.Length) | ||||||
|  |         { | ||||||
|  |             var ch = source[index]; | ||||||
|  |             if (ch == '"') | ||||||
|  |             { | ||||||
|  |                 return (builder.ToString(), index - startIndex); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (ch == '\\' && index + 1 < source.Length) | ||||||
|  |             { | ||||||
|  |                 var next = source[index + 1]; | ||||||
|  |                 if (next is '"' or '\\' or '$' or '`' or '\n') | ||||||
|  |                 { | ||||||
|  |                     builder.Append(next); | ||||||
|  |                     index += 2; | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             builder.Append(ch); | ||||||
|  |             index++; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         throw new FormatException("Unterminated double-quoted string in entrypoint script."); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static (string Value, int Length) ConsumeWord(string source, int startIndex) | ||||||
|  |     { | ||||||
|  |         var index = startIndex; | ||||||
|  |         while (index < source.Length) | ||||||
|  |         { | ||||||
|  |             var ch = source[index]; | ||||||
|  |             if (char.IsWhiteSpace(ch) || ch == '\n' || ch == '\r' || IsOperatorStart(ch) || ch == '#' ) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (ch == '\\' && index + 1 < source.Length && source[index + 1] == '\n') | ||||||
|  |             { | ||||||
|  |                 // Line continuation. | ||||||
|  |                 index += 2; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             index++; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (index == startIndex) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException("Tokenizer failed to advance while consuming word."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var text = source[startIndex..index]; | ||||||
|  |         return (text, index - startIndex); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | using Microsoft.Extensions.DependencyInjection; | ||||||
|  | using Microsoft.Extensions.DependencyInjection.Extensions; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Scanner.EntryTrace.Diagnostics; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.EntryTrace; | ||||||
|  |  | ||||||
|  | public static class ServiceCollectionExtensions | ||||||
|  | { | ||||||
|  |     public static IServiceCollection AddEntryTraceAnalyzer(this IServiceCollection services, Action<EntryTraceAnalyzerOptions>? configure = null) | ||||||
|  |     { | ||||||
|  |         if (services is null) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentNullException(nameof(services)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         services.AddOptions<EntryTraceAnalyzerOptions>() | ||||||
|  |             .BindConfiguration(EntryTraceAnalyzerOptions.SectionName); | ||||||
|  |  | ||||||
|  |         if (configure is not null) | ||||||
|  |         { | ||||||
|  |             services.Configure(configure); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         services.TryAddSingleton<EntryTraceMetrics>(); | ||||||
|  |         services.TryAddSingleton<IEntryTraceAnalyzer, EntryTraceAnalyzer>(); | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,18 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk"> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <TargetFramework>net10.0</TargetFramework> | ||||||
|  |     <LangVersion>preview</LangVersion> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||||
|  |   </PropertyGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" /> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" /> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" /> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  | </Project> | ||||||
							
								
								
									
										11
									
								
								src/StellaOps.Scanner.EntryTrace/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/StellaOps.Scanner.EntryTrace/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | # EntryTrace Analyzer Task Board (Sprint 10) | ||||||
|  |  | ||||||
|  | | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||||
|  | |----|--------|----------|------------|-------------|---------------| | ||||||
|  | | SCANNER-ENTRYTRACE-10-401 | DONE (2025-10-19) | EntryTrace Guild | Scanner Core contracts | Implement deterministic POSIX shell AST parser covering exec/command/source/run-parts/case/if used by ENTRYPOINT scripts. | Parser emits stable AST and serialization tests prove determinism for representative fixtures; see `ShellParserTests`. | | ||||||
|  | | SCANNER-ENTRYTRACE-10-402 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401 | Resolve commands across layered rootfs, tracking evidence per hop (PATH hit, layer origin, shebang). | Resolver returns terminal program path with layer attribution for fixtures; deterministic traversal asserted in `EntryTraceAnalyzerTests.ResolveAsync_IsDeterministic`. | | ||||||
|  | | SCANNER-ENTRYTRACE-10-403 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Follow interpreter wrappers (shell → Python/Node/Java launchers) to terminal target, including module/jar detection. | Interpreter tracer reports correct module/script for language launchers; tests cover Python/Node/Java wrappers. | | ||||||
|  | | SCANNER-ENTRYTRACE-10-404 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Build Python entry analyzer detecting venv shebangs, module invocations, `-m` usage and record usage flag. | Python fixtures produce expected module metadata (`python-module` edge) and diagnostics for missing scripts. | | ||||||
|  | | SCANNER-ENTRYTRACE-10-405 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Implement Node/Java launcher analyzer capturing script/jar targets including npm lifecycle wrappers. | Node/Java fixtures resolved with evidence chain; `RunParts` coverage ensures child scripts traced. | | ||||||
|  | | SCANNER-ENTRYTRACE-10-406 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Surface explainability + diagnostics for unresolved constructs and emit metrics counters. | Diagnostics catalog enumerates unknown reasons; metrics wired via `EntryTraceMetrics`; explainability doc updated. | | ||||||
|  | | SCANNER-ENTRYTRACE-10-407 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401..406 | Package EntryTrace analyzers as restart-time plug-ins with manifest + host registration. | Plug-in manifest under `plugins/scanner/entrytrace/`; restart-only policy documented; DI extension exposes `AddEntryTraceAnalyzer`. | | ||||||
| @@ -0,0 +1,132 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.IO; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Text.Json; | ||||||
|  | using System.Text.Json.Nodes; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using Microsoft.Extensions.Time.Testing; | ||||||
|  | using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||||
|  | using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Descriptor; | ||||||
|  |  | ||||||
|  | public sealed class DescriptorGoldenTests | ||||||
|  | { | ||||||
|  |     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||||
|  |     { | ||||||
|  |         WriteIndented = true, | ||||||
|  |         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task DescriptorMatchesBaselineFixture() | ||||||
|  |     { | ||||||
|  |         await using var temp = new TempDirectory(); | ||||||
|  |         var sbomPath = Path.Combine(temp.Path, "sample.cdx.json"); | ||||||
|  |         await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}"); | ||||||
|  |  | ||||||
|  |         var request = new DescriptorRequest | ||||||
|  |         { | ||||||
|  |             ImageDigest = "sha256:0123456789abcdef", | ||||||
|  |             SbomPath = sbomPath, | ||||||
|  |             SbomMediaType = "application/vnd.cyclonedx+json", | ||||||
|  |             SbomFormat = "cyclonedx-json", | ||||||
|  |             SbomKind = "inventory", | ||||||
|  |             SbomArtifactType = "application/vnd.stellaops.sbom.layer+json", | ||||||
|  |             SubjectMediaType = "application/vnd.oci.image.manifest.v1+json", | ||||||
|  |             GeneratorVersion = "1.2.3", | ||||||
|  |             GeneratorName = "StellaOps.Scanner.Sbomer.BuildXPlugin", | ||||||
|  |             LicenseId = "lic-123", | ||||||
|  |             SbomName = "sample.cdx.json", | ||||||
|  |             Repository = "git.stella-ops.org/stellaops", | ||||||
|  |             BuildRef = "refs/heads/main", | ||||||
|  |             AttestorUri = "https://attestor.local/api/v1/provenance" | ||||||
|  |         }.Validate(); | ||||||
|  |  | ||||||
|  |         var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); | ||||||
|  |         var generator = new DescriptorGenerator(fakeTime); | ||||||
|  |         var document = await generator.CreateAsync(request, CancellationToken.None); | ||||||
|  |         var actualJson = JsonSerializer.Serialize(document, SerializerOptions); | ||||||
|  |         var normalizedJson = NormalizeDescriptorJson(actualJson, Path.GetFileName(sbomPath)); | ||||||
|  |  | ||||||
|  |         var projectRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..")); | ||||||
|  |         var fixturePath = Path.Combine(projectRoot, "Fixtures", "descriptor.baseline.json"); | ||||||
|  |         var updateRequested = string.Equals(Environment.GetEnvironmentVariable("UPDATE_BUILDX_FIXTURES"), "1", StringComparison.OrdinalIgnoreCase); | ||||||
|  |  | ||||||
|  |         if (updateRequested) | ||||||
|  |         { | ||||||
|  |             Directory.CreateDirectory(Path.GetDirectoryName(fixturePath)!); | ||||||
|  |             await File.WriteAllTextAsync(fixturePath, normalizedJson); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!File.Exists(fixturePath)) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException($"Baseline fixture '{fixturePath}' is missing. Set UPDATE_BUILDX_FIXTURES=1 and re-run the tests to generate it."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var baselineJson = await File.ReadAllTextAsync(fixturePath); | ||||||
|  |  | ||||||
|  |         using var baselineDoc = JsonDocument.Parse(baselineJson); | ||||||
|  |         using var actualDoc = JsonDocument.Parse(normalizedJson); | ||||||
|  |  | ||||||
|  |         AssertJsonEquivalent(baselineDoc.RootElement, actualDoc.RootElement); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string NormalizeDescriptorJson(string json, string sbomFileName) | ||||||
|  |     { | ||||||
|  |         var node = JsonNode.Parse(json)?.AsObject() | ||||||
|  |                    ?? throw new InvalidOperationException("Failed to parse descriptor JSON for normalization."); | ||||||
|  |  | ||||||
|  |         if (node["metadata"] is JsonObject metadata) | ||||||
|  |         { | ||||||
|  |             metadata["sbomPath"] = sbomFileName; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return node.ToJsonString(SerializerOptions); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static void AssertJsonEquivalent(JsonElement expected, JsonElement actual) | ||||||
|  |     { | ||||||
|  |         if (expected.ValueKind != actual.ValueKind) | ||||||
|  |         { | ||||||
|  |             throw new Xunit.Sdk.XunitException($"Value kind mismatch. Expected '{expected.ValueKind}' but found '{actual.ValueKind}'."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         switch (expected.ValueKind) | ||||||
|  |         { | ||||||
|  |             case JsonValueKind.Object: | ||||||
|  |                 var expectedProperties = expected.EnumerateObject().ToDictionary(p => p.Name, p => p.Value, StringComparer.Ordinal); | ||||||
|  |                 var actualProperties = actual.EnumerateObject().ToDictionary(p => p.Name, p => p.Value, StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |                 Assert.Equal( | ||||||
|  |                     expectedProperties.Keys.OrderBy(static name => name).ToArray(), | ||||||
|  |                     actualProperties.Keys.OrderBy(static name => name).ToArray()); | ||||||
|  |  | ||||||
|  |                 foreach (var propertyName in expectedProperties.Keys) | ||||||
|  |                 { | ||||||
|  |                     AssertJsonEquivalent(expectedProperties[propertyName], actualProperties[propertyName]); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 break; | ||||||
|  |             case JsonValueKind.Array: | ||||||
|  |                 var expectedItems = expected.EnumerateArray().ToArray(); | ||||||
|  |                 var actualItems = actual.EnumerateArray().ToArray(); | ||||||
|  |  | ||||||
|  |                 Assert.Equal(expectedItems.Length, actualItems.Length); | ||||||
|  |                 for (var i = 0; i < expectedItems.Length; i++) | ||||||
|  |                 { | ||||||
|  |                     AssertJsonEquivalent(expectedItems[i], actualItems[i]); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 break; | ||||||
|  |             default: | ||||||
|  |                 Assert.Equal(expected.ToString(), actual.ToString()); | ||||||
|  |                 break; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,45 @@ | |||||||
|  | { | ||||||
|  |   "schema": "stellaops.buildx.descriptor.v1", | ||||||
|  |   "generatedAt": "2025-10-18T12:00:00\u002B00:00", | ||||||
|  |   "generator": { | ||||||
|  |     "name": "StellaOps.Scanner.Sbomer.BuildXPlugin", | ||||||
|  |     "version": "1.2.3" | ||||||
|  |   }, | ||||||
|  |   "subject": { | ||||||
|  |     "mediaType": "application/vnd.oci.image.manifest.v1\u002Bjson", | ||||||
|  |     "digest": "sha256:0123456789abcdef" | ||||||
|  |   }, | ||||||
|  |   "artifact": { | ||||||
|  |     "mediaType": "application/vnd.cyclonedx\u002Bjson", | ||||||
|  |     "digest": "sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c", | ||||||
|  |     "size": 45, | ||||||
|  |     "annotations": { | ||||||
|  |       "org.opencontainers.artifact.type": "application/vnd.stellaops.sbom.layer\u002Bjson", | ||||||
|  |       "org.stellaops.scanner.version": "1.2.3", | ||||||
|  |       "org.stellaops.sbom.kind": "inventory", | ||||||
|  |       "org.stellaops.sbom.format": "cyclonedx-json", | ||||||
|  |       "org.stellaops.provenance.status": "pending", | ||||||
|  |       "org.stellaops.provenance.dsse.sha256": "sha256:1b364a6b888d580feb8565f7b6195b24535ca8201b4bcac58da063b32c47220d", | ||||||
|  |       "org.stellaops.provenance.nonce": "a608acf859cd58a8389816b8d9eb2a07", | ||||||
|  |       "org.stellaops.license.id": "lic-123", | ||||||
|  |       "org.opencontainers.image.title": "sample.cdx.json", | ||||||
|  |       "org.stellaops.repository": "git.stella-ops.org/stellaops" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "provenance": { | ||||||
|  |     "status": "pending", | ||||||
|  |     "expectedDsseSha256": "sha256:1b364a6b888d580feb8565f7b6195b24535ca8201b4bcac58da063b32c47220d", | ||||||
|  |     "nonce": "a608acf859cd58a8389816b8d9eb2a07", | ||||||
|  |     "attestorUri": "https://attestor.local/api/v1/provenance", | ||||||
|  |     "predicateType": "https://slsa.dev/provenance/v1" | ||||||
|  |   }, | ||||||
|  |   "metadata": { | ||||||
|  |     "sbomDigest": "sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c", | ||||||
|  |     "sbomPath": "sample.cdx.json", | ||||||
|  |     "sbomMediaType": "application/vnd.cyclonedx\u002Bjson", | ||||||
|  |     "subjectMediaType": "application/vnd.oci.image.manifest.v1\u002Bjson", | ||||||
|  |     "repository": "git.stella-ops.org/stellaops", | ||||||
|  |     "buildRef": "refs/heads/main", | ||||||
|  |     "attestorUri": "https://attestor.local/api/v1/provenance" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -8,4 +8,10 @@ | |||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <ProjectReference Include="..\StellaOps.Scanner.Sbomer.BuildXPlugin\StellaOps.Scanner.Sbomer.BuildXPlugin.csproj" /> |     <ProjectReference Include="..\StellaOps.Scanner.Sbomer.BuildXPlugin\StellaOps.Scanner.Sbomer.BuildXPlugin.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |     <None Include="Fixtures\descriptor.baseline.json"> | ||||||
|  |       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> | ||||||
|  |     </None> | ||||||
|  |   </ItemGroup> | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -48,8 +48,7 @@ public sealed class DescriptorGenerator | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var sbomDigest = await ComputeFileDigestAsync(sbomFile, cancellationToken).ConfigureAwait(false); |         var sbomDigest = await ComputeFileDigestAsync(sbomFile, cancellationToken).ConfigureAwait(false); | ||||||
|  |         var nonce = ComputeDeterministicNonce(request, sbomFile, sbomDigest); | ||||||
|         var nonce = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); |  | ||||||
|         var expectedDsseSha = ComputeExpectedDsseDigest(request.ImageDigest, sbomDigest, nonce); |         var expectedDsseSha = ComputeExpectedDsseDigest(request.ImageDigest, sbomDigest, nonce); | ||||||
|  |  | ||||||
|         var artifactAnnotations = BuildArtifactAnnotations(request, nonce, expectedDsseSha); |         var artifactAnnotations = BuildArtifactAnnotations(request, nonce, expectedDsseSha); | ||||||
| @@ -87,6 +86,36 @@ public sealed class DescriptorGenerator | |||||||
|             Metadata: metadata); |             Metadata: metadata); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private static string ComputeDeterministicNonce(DescriptorRequest request, FileInfo sbomFile, string sbomDigest) | ||||||
|  |     { | ||||||
|  |         var builder = new StringBuilder(); | ||||||
|  |         builder.AppendLine("stellaops.buildx.nonce.v1"); | ||||||
|  |         builder.AppendLine(request.ImageDigest); | ||||||
|  |         builder.AppendLine(sbomDigest); | ||||||
|  |         builder.AppendLine(sbomFile.Length.ToString(CultureInfo.InvariantCulture)); | ||||||
|  |         builder.AppendLine(request.SbomMediaType); | ||||||
|  |         builder.AppendLine(request.SbomFormat); | ||||||
|  |         builder.AppendLine(request.SbomKind); | ||||||
|  |         builder.AppendLine(request.SbomArtifactType); | ||||||
|  |         builder.AppendLine(request.SubjectMediaType); | ||||||
|  |         builder.AppendLine(request.GeneratorVersion); | ||||||
|  |         builder.AppendLine(request.GeneratorName ?? string.Empty); | ||||||
|  |         builder.AppendLine(request.LicenseId ?? string.Empty); | ||||||
|  |         builder.AppendLine(request.SbomName ?? string.Empty); | ||||||
|  |         builder.AppendLine(request.Repository ?? string.Empty); | ||||||
|  |         builder.AppendLine(request.BuildRef ?? string.Empty); | ||||||
|  |         builder.AppendLine(request.AttestorUri ?? string.Empty); | ||||||
|  |         builder.AppendLine(request.PredicateType); | ||||||
|  |  | ||||||
|  |         var payload = Encoding.UTF8.GetBytes(builder.ToString()); | ||||||
|  |         Span<byte> hash = stackalloc byte[32]; | ||||||
|  |         SHA256.HashData(payload, hash); | ||||||
|  |  | ||||||
|  |         Span<byte> nonceBytes = stackalloc byte[16]; | ||||||
|  |         hash[..16].CopyTo(nonceBytes); | ||||||
|  |         return Convert.ToHexString(nonceBytes).ToLowerInvariant(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private static async Task<string> ComputeFileDigestAsync(FileInfo file, CancellationToken cancellationToken) |     private static async Task<string> ComputeFileDigestAsync(FileInfo file, CancellationToken cancellationToken) | ||||||
|     { |     { | ||||||
|         await using var stream = new FileStream( |         await using var stream = new FileStream( | ||||||
|   | |||||||
| @@ -5,3 +5,5 @@ | |||||||
| | SP9-BLDX-09-001 | DONE | BuildX Guild | SCANNER-EMIT-10-601 (awareness) | Scaffold buildx driver, manifest, local CAS handshake; ensure plugin loads from `plugins/scanner/buildx/`. | Plugin manifest + loader tests; local CAS writes succeed; restart required to activate. | | | SP9-BLDX-09-001 | DONE | BuildX Guild | SCANNER-EMIT-10-601 (awareness) | Scaffold buildx driver, manifest, local CAS handshake; ensure plugin loads from `plugins/scanner/buildx/`. | Plugin manifest + loader tests; local CAS writes succeed; restart required to activate. | | ||||||
| | SP9-BLDX-09-002 | DONE | BuildX Guild | SP9-BLDX-09-001 | Emit OCI annotations + provenance metadata for Attestor handoff (image + SBOM). | OCI descriptors include DSSE/provenance placeholders; Attestor mock accepts payload. | | | SP9-BLDX-09-002 | DONE | BuildX Guild | SP9-BLDX-09-001 | Emit OCI annotations + provenance metadata for Attestor handoff (image + SBOM). | OCI descriptors include DSSE/provenance placeholders; Attestor mock accepts payload. | | ||||||
| | SP9-BLDX-09-003 | DONE | BuildX Guild | SP9-BLDX-09-002 | CI demo pipeline: build sample image, produce SBOM, verify backend report wiring. | GitHub/CI job runs sample build within 5 s overhead; artifacts saved; documentation updated. | | | SP9-BLDX-09-003 | DONE | BuildX Guild | SP9-BLDX-09-002 | CI demo pipeline: build sample image, produce SBOM, verify backend report wiring. | GitHub/CI job runs sample build within 5 s overhead; artifacts saved; documentation updated. | | ||||||
|  | | SP9-BLDX-09-004 | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-002 | Stabilize descriptor nonce derivation so repeated builds emit deterministic placeholders. | Repeated descriptor runs with fixed inputs yield identical JSON; regression tests cover nonce determinism. | | ||||||
|  | | SP9-BLDX-09-005 | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-004 | Integrate determinism check in GitHub/Gitea workflows and capture sample artifacts. | Determinism step runs in `.gitea/workflows/build-test-deploy.yml` and `samples/ci/buildx-demo`, producing matching descriptors + archived artifacts. | | ||||||
|   | |||||||
| @@ -106,12 +106,13 @@ public sealed partial class ScannerWorkerHostedService : BackgroundService | |||||||
|         _metrics.RecordQueueLatency(context, queueLatency); |         _metrics.RecordQueueLatency(context, queueLatency); | ||||||
|         JobAcquired(_logger, lease.JobId, lease.ScanId, lease.Attempt, queueLatency.TotalMilliseconds); |         JobAcquired(_logger, lease.JobId, lease.ScanId, lease.Attempt, queueLatency.TotalMilliseconds); | ||||||
|  |  | ||||||
|  |         var processingTask = _processor.ExecuteAsync(context, jobToken).AsTask(); | ||||||
|         var heartbeatTask = _heartbeatService.RunAsync(lease, jobToken); |         var heartbeatTask = _heartbeatService.RunAsync(lease, jobToken); | ||||||
|         Exception? processingException = null; |         Exception? processingException = null; | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             await _processor.ExecuteAsync(context, jobToken).ConfigureAwait(false); |             await processingTask.ConfigureAwait(false); | ||||||
|             jobCts.Cancel(); |             jobCts.Cancel(); | ||||||
|             await heartbeatTask.ConfigureAwait(false); |             await heartbeatTask.ConfigureAwait(false); | ||||||
|             await lease.CompleteAsync(stoppingToken).ConfigureAwait(false); |             await lease.CompleteAsync(stoppingToken).ConfigureAwait(false); | ||||||
|   | |||||||
| @@ -2,6 +2,8 @@ using System; | |||||||
| using System.Collections.Concurrent; | using System.Collections.Concurrent; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Collections.ObjectModel; | using System.Collections.ObjectModel; | ||||||
|  | using System.IO; | ||||||
|  | using StellaOps.Scanner.Core.Contracts; | ||||||
|  |  | ||||||
| namespace StellaOps.Scanner.Worker.Options; | namespace StellaOps.Scanner.Worker.Options; | ||||||
|  |  | ||||||
| @@ -21,6 +23,8 @@ public sealed class ScannerWorkerOptions | |||||||
|  |  | ||||||
|     public ShutdownOptions Shutdown { get; } = new(); |     public ShutdownOptions Shutdown { get; } = new(); | ||||||
|  |  | ||||||
|  |     public AnalyzerOptions Analyzers { get; } = new(); | ||||||
|  |  | ||||||
|     public sealed class QueueOptions |     public sealed class QueueOptions | ||||||
|     { |     { | ||||||
|         public int MaxAttempts { get; set; } = 5; |         public int MaxAttempts { get; set; } = 5; | ||||||
| @@ -139,4 +143,21 @@ public sealed class ScannerWorkerOptions | |||||||
|     { |     { | ||||||
|         public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); |         public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public sealed class AnalyzerOptions | ||||||
|  |     { | ||||||
|  |         public AnalyzerOptions() | ||||||
|  |         { | ||||||
|  |             PluginDirectories = new List<string> | ||||||
|  |             { | ||||||
|  |                 Path.Combine("plugins", "scanner", "analyzers", "os"), | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public IList<string> PluginDirectories { get; } | ||||||
|  |  | ||||||
|  |         public string RootFilesystemMetadataKey { get; set; } = ScanMetadataKeys.RootFilesystemPath; | ||||||
|  |  | ||||||
|  |         public string WorkspaceMetadataKey { get; set; } = ScanMetadataKeys.WorkspacePath; | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -18,9 +18,9 @@ public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWork | |||||||
|             failures.Add("Scanner.Worker:MaxConcurrentJobs must be greater than zero."); |             failures.Add("Scanner.Worker:MaxConcurrentJobs must be greater than zero."); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (options.Queue.HeartbeatSafetyFactor < 2.0) |         if (options.Queue.HeartbeatSafetyFactor < 3.0) | ||||||
|         { |         { | ||||||
|             failures.Add("Scanner.Worker:Queue:HeartbeatSafetyFactor must be at least 2."); |             failures.Add("Scanner.Worker:Queue:HeartbeatSafetyFactor must be at least 3."); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (options.Queue.MaxAttempts <= 0) |         if (options.Queue.MaxAttempts <= 0) | ||||||
| @@ -94,6 +94,11 @@ public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWork | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(options.Analyzers.RootFilesystemMetadataKey)) | ||||||
|  |         { | ||||||
|  |             failures.Add("Scanner.Worker:Analyzers:RootFilesystemMetadataKey must be provided."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); |         return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -26,13 +26,13 @@ public sealed class LeaseHeartbeatService | |||||||
|     { |     { | ||||||
|         ArgumentNullException.ThrowIfNull(lease); |         ArgumentNullException.ThrowIfNull(lease); | ||||||
|  |  | ||||||
|         var options = _options.CurrentValue; |         await Task.Yield(); | ||||||
|         var interval = ComputeInterval(options, lease); |  | ||||||
|  |  | ||||||
|         while (!cancellationToken.IsCancellationRequested) |         while (!cancellationToken.IsCancellationRequested) | ||||||
|         { |         { | ||||||
|             options = _options.CurrentValue; |             var options = _options.CurrentValue; | ||||||
|             var delay = ApplyJitter(interval, options.Queue.MaxHeartbeatJitterMilliseconds); |             var interval = ComputeInterval(options, lease); | ||||||
|  |             var delay = ApplyJitter(interval, options.Queue); | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false); |                 await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false); | ||||||
| @@ -63,7 +63,8 @@ public sealed class LeaseHeartbeatService | |||||||
|     private static TimeSpan ComputeInterval(ScannerWorkerOptions options, IScanJobLease lease) |     private static TimeSpan ComputeInterval(ScannerWorkerOptions options, IScanJobLease lease) | ||||||
|     { |     { | ||||||
|         var divisor = options.Queue.HeartbeatSafetyFactor <= 0 ? 3.0 : options.Queue.HeartbeatSafetyFactor; |         var divisor = options.Queue.HeartbeatSafetyFactor <= 0 ? 3.0 : options.Queue.HeartbeatSafetyFactor; | ||||||
|         var recommended = TimeSpan.FromTicks((long)(lease.LeaseDuration.Ticks / Math.Max(2.0, divisor))); |         var safetyFactor = Math.Max(3.0, divisor); | ||||||
|  |         var recommended = TimeSpan.FromTicks((long)(lease.LeaseDuration.Ticks / safetyFactor)); | ||||||
|         if (recommended < options.Queue.MinHeartbeatInterval) |         if (recommended < options.Queue.MinHeartbeatInterval) | ||||||
|         { |         { | ||||||
|             recommended = options.Queue.MinHeartbeatInterval; |             recommended = options.Queue.MinHeartbeatInterval; | ||||||
| @@ -76,15 +77,21 @@ public sealed class LeaseHeartbeatService | |||||||
|         return recommended; |         return recommended; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static TimeSpan ApplyJitter(TimeSpan duration, int maxJitterMilliseconds) |     private static TimeSpan ApplyJitter(TimeSpan duration, ScannerWorkerOptions.QueueOptions queueOptions) | ||||||
|     { |     { | ||||||
|         if (maxJitterMilliseconds <= 0) |         if (queueOptions.MaxHeartbeatJitterMilliseconds <= 0) | ||||||
|         { |         { | ||||||
|             return duration; |             return duration; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var offset = Random.Shared.NextDouble() * maxJitterMilliseconds; |         var offsetMs = Random.Shared.NextDouble() * queueOptions.MaxHeartbeatJitterMilliseconds; | ||||||
|         return duration + TimeSpan.FromMilliseconds(offset); |         var adjusted = duration - TimeSpan.FromMilliseconds(offsetMs); | ||||||
|  |         if (adjusted < queueOptions.MinHeartbeatInterval) | ||||||
|  |         { | ||||||
|  |             return queueOptions.MinHeartbeatInterval; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return adjusted > TimeSpan.Zero ? adjusted : queueOptions.MinHeartbeatInterval; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task<bool> TryRenewAsync(ScannerWorkerOptions options, IScanJobLease lease, CancellationToken cancellationToken) |     private async Task<bool> TryRenewAsync(ScannerWorkerOptions options, IScanJobLease lease, CancellationToken cancellationToken) | ||||||
|   | |||||||
| @@ -0,0 +1,153 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Collections.ObjectModel; | ||||||
|  | using System.IO; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using Microsoft.Extensions.DependencyInjection; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Scanner.Analyzers.OS; | ||||||
|  | using StellaOps.Scanner.Analyzers.OS.Abstractions; | ||||||
|  | using StellaOps.Scanner.Analyzers.OS.Plugin; | ||||||
|  | using StellaOps.Scanner.Core.Contracts; | ||||||
|  | using StellaOps.Scanner.Worker.Options; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.Worker.Processing; | ||||||
|  |  | ||||||
|  | internal sealed class OsScanAnalyzerDispatcher : IScanAnalyzerDispatcher | ||||||
|  | { | ||||||
|  |     private readonly IServiceScopeFactory _scopeFactory; | ||||||
|  |     private readonly OsAnalyzerPluginCatalog _catalog; | ||||||
|  |     private readonly ScannerWorkerOptions _options; | ||||||
|  |     private readonly ILogger<OsScanAnalyzerDispatcher> _logger; | ||||||
|  |     private IReadOnlyList<string> _pluginDirectories = Array.Empty<string>(); | ||||||
|  |  | ||||||
|  |     public OsScanAnalyzerDispatcher( | ||||||
|  |         IServiceScopeFactory scopeFactory, | ||||||
|  |         OsAnalyzerPluginCatalog catalog, | ||||||
|  |         IOptions<ScannerWorkerOptions> options, | ||||||
|  |         ILogger<OsScanAnalyzerDispatcher> logger) | ||||||
|  |     { | ||||||
|  |         _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); | ||||||
|  |         _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); | ||||||
|  |         _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); | ||||||
|  |         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|  |  | ||||||
|  |         LoadPlugins(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(context); | ||||||
|  |  | ||||||
|  |         using var scope = _scopeFactory.CreateScope(); | ||||||
|  |         var services = scope.ServiceProvider; | ||||||
|  |         var analyzers = _catalog.CreateAnalyzers(services); | ||||||
|  |         if (analyzers.Count == 0) | ||||||
|  |         { | ||||||
|  |             _logger.LogWarning("No OS analyzers available; skipping analyzer stage for job {JobId}.", context.JobId); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var metadata = new Dictionary<string, string>(context.Lease.Metadata, StringComparer.Ordinal); | ||||||
|  |         var rootfsPath = ResolvePath(metadata, _options.Analyzers.RootFilesystemMetadataKey); | ||||||
|  |         if (rootfsPath is null) | ||||||
|  |         { | ||||||
|  |             _logger.LogWarning( | ||||||
|  |                 "Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. OS analyzers skipped.", | ||||||
|  |                 _options.Analyzers.RootFilesystemMetadataKey, | ||||||
|  |                 context.JobId); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var workspacePath = ResolvePath(metadata, _options.Analyzers.WorkspaceMetadataKey); | ||||||
|  |         var loggerFactory = services.GetRequiredService<ILoggerFactory>(); | ||||||
|  |  | ||||||
|  |         var results = new List<OSPackageAnalyzerResult>(analyzers.Count); | ||||||
|  |  | ||||||
|  |         foreach (var analyzer in analyzers) | ||||||
|  |         { | ||||||
|  |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |             var analyzerLogger = loggerFactory.CreateLogger(analyzer.GetType()); | ||||||
|  |             var analyzerContext = new OSPackageAnalyzerContext(rootfsPath, workspacePath, context.TimeProvider, analyzerLogger, metadata); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false); | ||||||
|  |                 results.Add(result); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Analyzer {AnalyzerId} failed for job {JobId}.", analyzer.AnalyzerId, context.JobId); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (results.Count > 0) | ||||||
|  |         { | ||||||
|  |             var dictionary = results.ToDictionary(result => result.AnalyzerId, StringComparer.OrdinalIgnoreCase); | ||||||
|  |             context.Analysis.Set(ScanAnalysisKeys.OsPackageAnalyzers, dictionary); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void LoadPlugins() | ||||||
|  |     { | ||||||
|  |         var directories = new List<string>(); | ||||||
|  |         foreach (var configured in _options.Analyzers.PluginDirectories) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(configured)) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var path = configured; | ||||||
|  |             if (!Path.IsPathRooted(path)) | ||||||
|  |             { | ||||||
|  |                 path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path)); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             directories.Add(path); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (directories.Count == 0) | ||||||
|  |         { | ||||||
|  |             directories.Add(Path.Combine(AppContext.BaseDirectory, "plugins", "scanner", "analyzers", "os")); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _pluginDirectories = new ReadOnlyCollection<string>(directories); | ||||||
|  |  | ||||||
|  |         for (var i = 0; i < _pluginDirectories.Count; i++) | ||||||
|  |         { | ||||||
|  |             var directory = _pluginDirectories[i]; | ||||||
|  |             var seal = i == _pluginDirectories.Count - 1; | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 _catalog.LoadFromDirectory(directory, seal); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning(ex, "Failed to load analyzer plug-ins from {Directory}.", directory); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? ResolvePath(IReadOnlyDictionary<string, string> metadata, string key) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(key)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var trimmed = value.Trim(); | ||||||
|  |         return Path.IsPathRooted(trimmed) | ||||||
|  |             ? trimmed | ||||||
|  |             : Path.GetFullPath(trimmed); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| using System; | using System; | ||||||
| using System.Threading; | using System.Threading; | ||||||
|  | using StellaOps.Scanner.Core.Contracts; | ||||||
|  |  | ||||||
| namespace StellaOps.Scanner.Worker.Processing; | namespace StellaOps.Scanner.Worker.Processing; | ||||||
|  |  | ||||||
| @@ -11,6 +12,7 @@ public sealed class ScanJobContext | |||||||
|         TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); |         TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||||
|         StartUtc = startUtc; |         StartUtc = startUtc; | ||||||
|         CancellationToken = cancellationToken; |         CancellationToken = cancellationToken; | ||||||
|  |         Analysis = new ScanAnalysisStore(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public IScanJobLease Lease { get; } |     public IScanJobLease Lease { get; } | ||||||
| @@ -24,4 +26,6 @@ public sealed class ScanJobContext | |||||||
|     public string JobId => Lease.JobId; |     public string JobId => Lease.JobId; | ||||||
|  |  | ||||||
|     public string ScanId => Lease.ScanId; |     public string ScanId => Lease.ScanId; | ||||||
|  |  | ||||||
|  |     public ScanAnalysisStore Analysis { get; } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,6 +5,8 @@ using Microsoft.Extensions.Logging; | |||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | using Microsoft.Extensions.DependencyInjection.Extensions; | ||||||
| using StellaOps.Auth.Client; | using StellaOps.Auth.Client; | ||||||
|  | using StellaOps.Scanner.Analyzers.OS.Plugin; | ||||||
|  | using StellaOps.Scanner.EntryTrace; | ||||||
| using StellaOps.Scanner.Worker.Diagnostics; | using StellaOps.Scanner.Worker.Diagnostics; | ||||||
| using StellaOps.Scanner.Worker.Hosting; | using StellaOps.Scanner.Worker.Hosting; | ||||||
| using StellaOps.Scanner.Worker.Options; | using StellaOps.Scanner.Worker.Options; | ||||||
| @@ -24,8 +26,11 @@ builder.Services.AddSingleton<ScanJobProcessor>(); | |||||||
| builder.Services.AddSingleton<LeaseHeartbeatService>(); | builder.Services.AddSingleton<LeaseHeartbeatService>(); | ||||||
| builder.Services.AddSingleton<IDelayScheduler, SystemDelayScheduler>(); | builder.Services.AddSingleton<IDelayScheduler, SystemDelayScheduler>(); | ||||||
|  |  | ||||||
|  | builder.Services.AddEntryTraceAnalyzer(); | ||||||
|  |  | ||||||
| builder.Services.TryAddSingleton<IScanJobSource, NullScanJobSource>(); | builder.Services.TryAddSingleton<IScanJobSource, NullScanJobSource>(); | ||||||
| builder.Services.TryAddSingleton<IScanAnalyzerDispatcher, NullScanAnalyzerDispatcher>(); | builder.Services.AddSingleton<OsAnalyzerPluginCatalog>(); | ||||||
|  | builder.Services.AddSingleton<IScanAnalyzerDispatcher, OsScanAnalyzerDispatcher>(); | ||||||
| builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>(); | builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>(); | ||||||
|  |  | ||||||
| builder.Services.AddSingleton<ScannerWorkerHostedService>(); | builder.Services.AddSingleton<ScannerWorkerHostedService>(); | ||||||
|   | |||||||
| @@ -16,5 +16,7 @@ | |||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> |     <ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> | ||||||
|     <ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> |     <ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> | ||||||
|  |     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" /> | ||||||
|  |     <ProjectReference Include="..\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -6,3 +6,4 @@ | |||||||
| | SCANNER-WORKER-09-202 | DONE (2025-10-19) | Scanner Worker Guild | SCANNER-WORKER-09-201, SCANNER-QUEUE-09-401 | Lease/heartbeat loop with retry+jitter, poison-job quarantine, structured logging. | `ScannerWorkerHostedService` + `LeaseHeartbeatService` manage concurrency, renewal margins, poison handling, and structured logs exercised by integration fixture. | | | SCANNER-WORKER-09-202 | DONE (2025-10-19) | Scanner Worker Guild | SCANNER-WORKER-09-201, SCANNER-QUEUE-09-401 | Lease/heartbeat loop with retry+jitter, poison-job quarantine, structured logging. | `ScannerWorkerHostedService` + `LeaseHeartbeatService` manage concurrency, renewal margins, poison handling, and structured logs exercised by integration fixture. | | ||||||
| | SCANNER-WORKER-09-203 | DONE (2025-10-19) | Scanner Worker Guild | SCANNER-WORKER-09-202, SCANNER-STORAGE-09-301 | Analyzer dispatch skeleton emitting deterministic stage progress and honoring cancellation tokens. | Deterministic stage list + `ScanProgressReporter`; `WorkerBasicScanScenario` validates ordering and cancellation propagation. | | | SCANNER-WORKER-09-203 | DONE (2025-10-19) | Scanner Worker Guild | SCANNER-WORKER-09-202, SCANNER-STORAGE-09-301 | Analyzer dispatch skeleton emitting deterministic stage progress and honoring cancellation tokens. | Deterministic stage list + `ScanProgressReporter`; `WorkerBasicScanScenario` validates ordering and cancellation propagation. | | ||||||
| | SCANNER-WORKER-09-204 | DONE (2025-10-19) | Scanner Worker Guild | SCANNER-WORKER-09-203 | Worker metrics (queue latency, stage duration, failure counts) with OpenTelemetry resource wiring. | `ScannerWorkerMetrics` records queue/job/stage metrics; integration test asserts analyzer stage histogram entries. | | | SCANNER-WORKER-09-204 | DONE (2025-10-19) | Scanner Worker Guild | SCANNER-WORKER-09-203 | Worker metrics (queue latency, stage duration, failure counts) with OpenTelemetry resource wiring. | `ScannerWorkerMetrics` records queue/job/stage metrics; integration test asserts analyzer stage histogram entries. | | ||||||
|  | | SCANNER-WORKER-09-205 | DONE (2025-10-19) | Scanner Worker Guild | SCANNER-WORKER-09-202 | Harden heartbeat jitter so lease safety margin stays ≥3× and cover with regression tests. | `LeaseHeartbeatService` clamps jitter to safety window, validator enforces ≥3 safety factor, regression tests cover heartbeat scheduling and metrics. | | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user