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
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-22.04
|
||||
environment: ${{ github.event_name == 'pull_request' && 'preview' || 'staging' }}
|
||||
@@ -70,6 +85,73 @@ jobs:
|
||||
--logger "trx;LogFileName=stellaops-feedser-tests.trx" \
|
||||
--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
|
||||
run: |
|
||||
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-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 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.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.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 | 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.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. |
|
||||
@@ -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-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 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 | 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-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-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-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-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. |
|
||||
@@ -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-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-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-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). |
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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.
|
||||
- Gate: `WorkerBasicScanScenario` integration recorded.
|
||||
- Status: **DONE (2025-10-19)** – Host bootstrap + authority wiring, heartbeat loop, deterministic stage pipeline, and metrics landed; `WorkerBasicScanScenarioTests` green.
|
||||
- Gate: `WorkerBasicScanScenario` integration recorded + optional live queue smoke validation.
|
||||
- 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
|
||||
- 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.
|
||||
- Gate: buildx demo workflow artifact + quickstart doc.
|
||||
- Status: **DONE** (2025-10-19) — manifest+CAS scaffold, descriptor/Attestor hand-off, GitHub demo workflow, and quickstart committed.
|
||||
- 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/Gitea determinism workflows, quickstart update, and golden tests committed.
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -162,6 +162,10 @@ GET /catalog/artifacts/{id} → { meta }
|
||||
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)
|
||||
@@ -433,6 +437,26 @@ ResolveEntrypoint(ImageConfig cfg, RootFs fs):
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -67,7 +67,7 @@ The command performs a deterministic probe write (`sha256`) into the provided CA
|
||||
The output JSON captures:
|
||||
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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 \
|
||||
> 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
|
||||
if: always()
|
||||
run: |
|
||||
@@ -114,6 +146,7 @@ PY
|
||||
out/buildx-descriptor.json
|
||||
out/buildx-sbom.cdx.json
|
||||
out/provenance-request.json
|
||||
out/buildx-descriptor-repeat.json
|
||||
|
||||
- name: Show descriptor summary
|
||||
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>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Sbomer.BuildXPlugin\StellaOps.Scanner.Sbomer.BuildXPlugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\descriptor.baseline.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -48,8 +48,7 @@ public sealed class DescriptorGenerator
|
||||
}
|
||||
|
||||
var sbomDigest = await ComputeFileDigestAsync(sbomFile, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var nonce = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
var nonce = ComputeDeterministicNonce(request, sbomFile, sbomDigest);
|
||||
var expectedDsseSha = ComputeExpectedDsseDigest(request.ImageDigest, sbomDigest, nonce);
|
||||
|
||||
var artifactAnnotations = BuildArtifactAnnotations(request, nonce, expectedDsseSha);
|
||||
@@ -87,6 +86,36 @@ public sealed class DescriptorGenerator
|
||||
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)
|
||||
{
|
||||
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-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-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);
|
||||
JobAcquired(_logger, lease.JobId, lease.ScanId, lease.Attempt, queueLatency.TotalMilliseconds);
|
||||
|
||||
var processingTask = _processor.ExecuteAsync(context, jobToken).AsTask();
|
||||
var heartbeatTask = _heartbeatService.RunAsync(lease, jobToken);
|
||||
Exception? processingException = null;
|
||||
|
||||
try
|
||||
{
|
||||
await _processor.ExecuteAsync(context, jobToken).ConfigureAwait(false);
|
||||
await processingTask.ConfigureAwait(false);
|
||||
jobCts.Cancel();
|
||||
await heartbeatTask.ConfigureAwait(false);
|
||||
await lease.CompleteAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
@@ -2,6 +2,8 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Options;
|
||||
|
||||
@@ -21,6 +23,8 @@ public sealed class ScannerWorkerOptions
|
||||
|
||||
public ShutdownOptions Shutdown { get; } = new();
|
||||
|
||||
public AnalyzerOptions Analyzers { get; } = new();
|
||||
|
||||
public sealed class QueueOptions
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
@@ -139,4 +143,21 @@ public sealed class ScannerWorkerOptions
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,13 @@ public sealed class LeaseHeartbeatService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lease);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
var interval = ComputeInterval(options, lease);
|
||||
await Task.Yield();
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
options = _options.CurrentValue;
|
||||
var delay = ApplyJitter(interval, options.Queue.MaxHeartbeatJitterMilliseconds);
|
||||
var options = _options.CurrentValue;
|
||||
var interval = ComputeInterval(options, lease);
|
||||
var delay = ApplyJitter(interval, options.Queue);
|
||||
try
|
||||
{
|
||||
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
|
||||
@@ -63,7 +63,8 @@ public sealed class LeaseHeartbeatService
|
||||
private static TimeSpan ComputeInterval(ScannerWorkerOptions options, IScanJobLease lease)
|
||||
{
|
||||
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)
|
||||
{
|
||||
recommended = options.Queue.MinHeartbeatInterval;
|
||||
@@ -76,15 +77,21 @@ public sealed class LeaseHeartbeatService
|
||||
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;
|
||||
}
|
||||
|
||||
var offset = Random.Shared.NextDouble() * maxJitterMilliseconds;
|
||||
return duration + TimeSpan.FromMilliseconds(offset);
|
||||
var offsetMs = Random.Shared.NextDouble() * queueOptions.MaxHeartbeatJitterMilliseconds;
|
||||
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)
|
||||
|
||||
@@ -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.Threading;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
@@ -11,6 +12,7 @@ public sealed class ScanJobContext
|
||||
TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
StartUtc = startUtc;
|
||||
CancellationToken = cancellationToken;
|
||||
Analysis = new ScanAnalysisStore();
|
||||
}
|
||||
|
||||
public IScanJobLease Lease { get; }
|
||||
@@ -24,4 +26,6 @@ public sealed class ScanJobContext
|
||||
public string JobId => Lease.JobId;
|
||||
|
||||
public string ScanId => Lease.ScanId;
|
||||
|
||||
public ScanAnalysisStore Analysis { get; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Scanner.Analyzers.OS.Plugin;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Hosting;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
@@ -24,8 +26,11 @@ builder.Services.AddSingleton<ScanJobProcessor>();
|
||||
builder.Services.AddSingleton<LeaseHeartbeatService>();
|
||||
builder.Services.AddSingleton<IDelayScheduler, SystemDelayScheduler>();
|
||||
|
||||
builder.Services.AddEntryTraceAnalyzer();
|
||||
|
||||
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<ScannerWorkerHostedService>();
|
||||
|
||||
@@ -16,5 +16,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.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>
|
||||
</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-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-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