Add scripts for resolving and verifying Chromium binary paths
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented `chrome-path.js` to define functions for locating Chromium binaries across different platforms and nested directories.
- Added `verify-chromium.js` to check for the presence of the Chromium binary and log the results, including candidate paths checked.
- The scripts support Linux, Windows, and macOS environments, enhancing the flexibility of Chromium binary detection.
This commit is contained in:
2025-10-22 09:14:36 +03:00
parent cfaea5efd9
commit 323bf5844f
131 changed files with 23191 additions and 3461 deletions

16
.gitignore vendored
View File

@@ -18,10 +18,12 @@ obj/
*.log
TestResults/
.dotnet
.DS_Store
seed-data/ics-cisa/*.csv
seed-data/ics-cisa/*.xlsx
seed-data/ics-cisa/*.sha256
seed-data/cert-bund/**/*.json
seed-data/cert-bund/**/*.sha256
.dotnet
.DS_Store
seed-data/ics-cisa/*.csv
seed-data/ics-cisa/*.xlsx
seed-data/ics-cisa/*.sha256
seed-data/cert-bund/**/*.json
seed-data/cert-bund/**/*.sha256
out/offline-kit/web/**/*

View File

@@ -13,7 +13,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team Docs/CLI: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on EXCITITOR-CLI-01-003 (TODO). Confirm prerequisites (external: EXCITITOR-CLI-01-001) before starting and report status in module TASKS.md.
- Team Emit Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Scanner.Emit/TASKS.md`. Focus on SCANNER-EMIT-10-601 (TODO), SCANNER-EMIT-10-602 (TODO), SCANNER-EMIT-10-603 (TODO), SCANNER-EMIT-10-604 (TODO), SCANNER-EMIT-10-605 (TODO), SCANNER-EMIT-10-606 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team EntryTrace Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Scanner.EntryTrace/TASKS.md`. Focus on SCANNER-ENTRYTRACE-10-401 (TODO), SCANNER-ENTRYTRACE-10-402 (TODO), SCANNER-ENTRYTRACE-10-403 (TODO), SCANNER-ENTRYTRACE-10-404 (TODO), SCANNER-ENTRYTRACE-10-405 (TODO), SCANNER-ENTRYTRACE-10-406 (TODO), SCANNER-ENTRYTRACE-10-407 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Language Analyzer Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang/SPRINTS_LANG_IMPLEMENTATION_PLAN.md`, `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-301 (TODO), SCANNER-ANALYZERS-LANG-10-307 (TODO), SCANNER-ANALYZERS-LANG-10-308 (TODO), SCANNER-ANALYZERS-LANG-10-302..309 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Language Analyzer Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang/SPRINTS_LANG_IMPLEMENTATION_PLAN.md`, `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-301 (TODO) and the upcoming Python/Go/.NET/Rust analyzers (10-303..306). Node sprint items 10-302/307/308/309 are DONE (latest 2025-10-21); shift coordination to remaining ecosystem analyzers and track follow-up work via module TASKS.md.
- Team Notify Models Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Notify.Models/TASKS.md`. Focus on NOTIFY-MODELS-15-101 (TODO), NOTIFY-MODELS-15-102 (TODO), NOTIFY-MODELS-15-103 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Notify Storage Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Notify.Storage.Mongo/TASKS.md`. Focus on NOTIFY-STORAGE-15-201 (TODO), NOTIFY-STORAGE-15-202 (TODO), NOTIFY-STORAGE-15-203 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Notify WebService Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Notify.WebService/TASKS.md`. Focus on NOTIFY-WEB-15-101 (TODO), NOTIFY-WEB-15-102 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
@@ -47,7 +47,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team Team Normalization & Storage Backbone: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Concelier.Storage.Mongo/TASKS.md`. Focus on FEEDSTORAGE-MONGO-08-001 (DONE 2025-10-19). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Team WebService & Authority: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md`, `src/StellaOps.Concelier.WebService/TASKS.md`. Focus on SEC2.PLG (DOING), SEC3.PLG (DOING), SEC5.PLG (DOING), PLG4-6.CAPABILITIES (BLOCKED), PLG6.DIAGRAM (TODO), PLG7.RFC (REVIEW), FEEDWEB-DOCS-01-001 (DOING), FEEDWEB-OPS-01-006 (TODO), FEEDWEB-OPS-01-007 (BLOCKED). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Tools Guild, BE-Conn-MSRC: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Concelier.Connector.Common/TASKS.md`. Focus on FEEDCONN-SHARED-STATE-003 (**TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team UX Specialist, Angular Eng: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Web/TASKS.md`. Focus on WEB1.TRIVY-SETTINGS (DONE 2025-10-21), WEB1.TRIVY-SETTINGS-TESTS (DONE 2025-10-21), and WEB1.DEPS-13-001 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team UX Specialist, Angular Eng: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Web/TASKS.md`. Focus on WEB1.TRIVY-SETTINGS (DONE 2025-10-21), WEB1.TRIVY-SETTINGS-TESTS (DONE 2025-10-21), and WEB1.DEPS-13-001 (DONE 2025-10-21). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Zastava Core Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Core/TASKS.md`. Focus on ZASTAVA-CORE-12-201 (TODO), ZASTAVA-CORE-12-202 (TODO), ZASTAVA-CORE-12-203 (TODO), ZASTAVA-OPS-12-204 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
- Team Zastava Webhook Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Webhook/TASKS.md`. Focus on ZASTAVA-WEBHOOK-12-101 (TODO), ZASTAVA-WEBHOOK-12-102 (TODO), ZASTAVA-WEBHOOK-12-103 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
@@ -57,7 +57,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team DevOps Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-REL-14-001 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), SIGNER-API-11-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team DevOps Guild, Scanner WebService Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SCANNER-09-204 (TODO). Confirm prerequisites (internal: SCANNER-EVENTS-15-201 (Wave 0)) before starting and report status in module TASKS.md.
- Team Emit Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.Emit/TASKS.md`. Focus on SCANNER-EMIT-10-607 (TODO), SCANNER-EMIT-17-701 (TODO). Confirm prerequisites (internal: POLICY-CORE-09-005 (Wave 0), SCANNER-EMIT-10-602 (Wave 0), SCANNER-EMIT-10-604 (Wave 0)) before starting and report status in module TASKS.md.
- Team Language Analyzer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-309 (DOING), SCANNER-ANALYZERS-LANG-10-306 (TODO), SCANNER-ANALYZERS-LANG-10-302 (DOING), SCANNER-ANALYZERS-LANG-10-304 (TODO), SCANNER-ANALYZERS-LANG-10-305 (TODO), SCANNER-ANALYZERS-LANG-10-303 (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-301 (Wave 0), SCANNER-ANALYZERS-LANG-10-307 (Wave 0)) before starting and report status in module TASKS.md.
- Team Language Analyzer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-303 (DONE 2025-10-21), SCANNER-ANALYZERS-LANG-10-304 (DOING 2025-10-22), SCANNER-ANALYZERS-LANG-10-305 (DOING 2025-10-22), SCANNER-ANALYZERS-LANG-10-306 (TODO). Node stream (tasks 10-302/309) closed on 2025-10-21; verify prereqs SCANNER-ANALYZERS-LANG-10-301/307 remain satisfied before pivoting to the remaining language analyzers.
- Team Licensing Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `ops/licensing/TASKS.md`. Focus on DEVOPS-LIC-14-004 (TODO). Confirm prerequisites (internal: AUTH-MTLS-11-002 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify Engine Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-301 (TODO). Confirm prerequisites (internal: NOTIFY-MODELS-15-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify Queue Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-401 (TODO). Confirm prerequisites (internal: NOTIFY-MODELS-15-101 (Wave 0)) before starting and report status in module TASKS.md.
@@ -68,7 +68,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team Scheduler Storage Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scheduler.Storage.Mongo/TASKS.md`. Focus on SCHED-STORAGE-16-203 (TODO), SCHED-STORAGE-16-202 (TODO). Confirm prerequisites (internal: SCHED-STORAGE-16-201 (Wave 0)) before starting and report status in module TASKS.md.
- Team Scheduler WebService Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scheduler.WebService/TASKS.md`. Focus on SCHED-WEB-16-104 (TODO), SCHED-WEB-16-102 (TODO). Confirm prerequisites (internal: SCHED-QUEUE-16-401 (Wave 0), SCHED-STORAGE-16-201 (Wave 0), SCHED-WEB-16-101 (Wave 0)) before starting and report status in module TASKS.md.
- Team Scheduler Worker Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scheduler.Worker/TASKS.md`. Focus on SCHED-WORKER-16-201 (TODO). Confirm prerequisites (internal: SCHED-QUEUE-16-401 (Wave 0)) before starting and report status in module TASKS.md.
- Team TBD: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-305A (TODO), SCANNER-ANALYZERS-LANG-10-304A (TODO), SCANNER-ANALYZERS-LANG-10-307N (TODO), SCANNER-ANALYZERS-LANG-10-303A (TODO), SCANNER-ANALYZERS-LANG-10-306A (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-302C (Wave 0), SCANNER-ANALYZERS-LANG-10-307 (Wave 0)) before starting and report status in module TASKS.md.
- Team TBD: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-305A (DONE 2025-10-22), SCANNER-ANALYZERS-LANG-10-304A (DONE 2025-10-22), SCANNER-ANALYZERS-LANG-10-303A (DONE 2025-10-21), SCANNER-ANALYZERS-LANG-10-306A (TODO); Node add-ons 10-307N/10-308N/10-309N now DONE with restart-time packaging verified 2025-10-21. Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-302C (Wave 0), SCANNER-ANALYZERS-LANG-10-307 (Wave 0)) before starting and report status in module TASKS.md.
- Team Team Excititor Connectors MSRC: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.MSRC.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-MS-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-MS-01-002 (Wave 0); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md.
- Team Team Excititor Connectors Oracle: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Oracle.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-ORACLE-01-002 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-ORACLE-01-001 (Wave 0); external: EXCITITOR-STORAGE-01-003) before starting and report status in module TASKS.md.
- Team Team Excititor Connectors SUSE: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/TASKS.md`. Focus on EXCITITOR-CONN-SUSE-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-SUSE-01-002 (Wave 0); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md.
@@ -88,13 +88,13 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team Notify Queue Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-403 (TODO), NOTIFY-QUEUE-15-402 (TODO). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
- Team Notify WebService Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.WebService/TASKS.md`. Focus on NOTIFY-WEB-15-104 (TODO). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1), NOTIFY-STORAGE-15-201 (Wave 0)) before starting and report status in module TASKS.md.
- Team Notify Worker Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Worker/TASKS.md`. Focus on NOTIFY-WORKER-15-201 (TODO), NOTIFY-WORKER-15-202 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1), NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
- Team Offline Kit Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/offline-kit/TASKS.md`. Focus on DEVOPS-OFFLINE-14-002 (TODO). Confirm prerequisites (internal: DEVOPS-REL-14-001 (Wave 1)) before starting and report status in module TASKS.md.
- Team Offline Kit Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/offline-kit/TASKS.md`. Focus on DEVOPS-OFFLINE-14-002 (TODO) and DEVOPS-OFFLINE-18-003 (TODO). Confirm prerequisites (internal: DEVOPS-REL-14-001 (Wave 1)) before starting and report status in module TASKS.md.
- Team Samples Guild, Policy Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `samples/TASKS.md`. Focus on SAMPLES-13-004 (TODO). Confirm prerequisites (internal: POLICY-CORE-09-006 (Wave 0), UI-POLICY-13-007 (Wave 1)) before starting and report status in module TASKS.md.
- Team Scanner WebService Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scanner.WebService/TASKS.md`. Focus on SCANNER-RUNTIME-12-302 (TODO). Confirm prerequisites (internal: SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md.
- Team Scheduler ImpactIndex Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scheduler.ImpactIndex/TASKS.md`. Focus on SCHED-IMPACT-16-303 (TODO), SCHED-IMPACT-16-302 (TODO). Confirm prerequisites (internal: SCHED-IMPACT-16-301 (Wave 1)) before starting and report status in module TASKS.md.
- Team Scheduler WebService Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scheduler.WebService/TASKS.md`. Focus on SCHED-WEB-16-103 (TODO). Confirm prerequisites (internal: SCHED-WEB-16-102 (Wave 1)) before starting and report status in module TASKS.md.
- Team Scheduler Worker Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scheduler.Worker/TASKS.md`. Focus on SCHED-WORKER-16-202 (TODO), SCHED-WORKER-16-205 (TODO). Confirm prerequisites (internal: SCHED-IMPACT-16-301 (Wave 1), SCHED-WORKER-16-201 (Wave 1)) before starting and report status in module TASKS.md.
- Team TBD: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-305B (TODO), SCANNER-ANALYZERS-LANG-10-304B (TODO), SCANNER-ANALYZERS-LANG-10-308N (TODO), SCANNER-ANALYZERS-LANG-10-303B (TODO), SCANNER-ANALYZERS-LANG-10-306B (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303A (Wave 1), SCANNER-ANALYZERS-LANG-10-304A (Wave 1), SCANNER-ANALYZERS-LANG-10-305A (Wave 1), SCANNER-ANALYZERS-LANG-10-306A (Wave 1), SCANNER-ANALYZERS-LANG-10-307N (Wave 1)) before starting and report status in module TASKS.md.
- Team TBD: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-305B (TODO), SCANNER-ANALYZERS-LANG-10-304B (DONE 2025-10-22), SCANNER-ANALYZERS-LANG-10-303B (DONE 2025-10-21), SCANNER-ANALYZERS-LANG-10-306B (TODO); Node packaging milestone 10-308N closed 2025-10-21. Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303A (Wave 1), SCANNER-ANALYZERS-LANG-10-304A (Wave 1), SCANNER-ANALYZERS-LANG-10-305A (Wave 1), SCANNER-ANALYZERS-LANG-10-306A (Wave 1), SCANNER-ANALYZERS-LANG-10-307N (Wave 1)) before starting and report status in module TASKS.md.
- Team Team Excititor Connectors Oracle: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Oracle.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-ORACLE-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-ORACLE-01-002 (Wave 1); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md.
- Team Team Excititor Export: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-007 (DONE 2025-10-21). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-006 (Wave 1)) before starting and report status in module TASKS.md.
- Team Zastava Observer Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-002 (TODO). Confirm prerequisites (internal: ZASTAVA-OBS-12-001 (Wave 1)) before starting and report status in module TASKS.md.
@@ -106,11 +106,11 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Team Notify Engine Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-303 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-302 (Wave 2)) before starting and report status in module TASKS.md.
- Team Notify Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Notify.Worker/TASKS.md`. Focus on NOTIFY-WORKER-15-203 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-302 (Wave 2)) before starting and report status in module TASKS.md.
- Team Scheduler Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scheduler.Worker/TASKS.md`. Focus on SCHED-WORKER-16-203 (TODO). Confirm prerequisites (internal: SCHED-WORKER-16-202 (Wave 2)) before starting and report status in module TASKS.md.
- Team TBD: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-305C (TODO), SCANNER-ANALYZERS-LANG-10-304C (TODO), SCANNER-ANALYZERS-LANG-10-309N (TODO), SCANNER-ANALYZERS-LANG-10-303C (TODO), SCANNER-ANALYZERS-LANG-10-306C (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303B (Wave 2), SCANNER-ANALYZERS-LANG-10-304B (Wave 2), SCANNER-ANALYZERS-LANG-10-305B (Wave 2), SCANNER-ANALYZERS-LANG-10-306B (Wave 2), SCANNER-ANALYZERS-LANG-10-308N (Wave 2)) before starting and report status in module TASKS.md.
- Team TBD: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. Focus on SCANNER-ANALYZERS-LANG-10-305C (TODO), SCANNER-ANALYZERS-LANG-10-304C (TODO), SCANNER-ANALYZERS-LANG-10-309N (TODO), SCANNER-ANALYZERS-LANG-10-303C (DONE 2025-10-21), SCANNER-ANALYZERS-LANG-10-306C (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303B (Wave 2), SCANNER-ANALYZERS-LANG-10-304B (Wave 2), SCANNER-ANALYZERS-LANG-10-305B (Wave 2), SCANNER-ANALYZERS-LANG-10-306B (Wave 2), SCANNER-ANALYZERS-LANG-10-308N (Wave 2)) before starting and report status in module TASKS.md.
- Team Zastava Observer Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-003 (TODO), ZASTAVA-OBS-12-004 (TODO), ZASTAVA-OBS-17-005 (TODO). Confirm prerequisites (internal: ZASTAVA-OBS-12-002 (Wave 2)) before starting and report status in module TASKS.md.
### Wave 4
- Team DevEx/CLI: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-PLUGIN-13-007 (TODO). Confirm prerequisites (internal: CLI-OFFLINE-13-006 (Wave 3), CLI-RUNTIME-13-005 (Wave 0)) before starting and report status in module TASKS.md.
- Team DevEx/CLI: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-PLUGIN-13-007 (DONE 2025-10-22). Confirm prerequisites (internal: CLI-OFFLINE-13-006 (Wave 3), CLI-RUNTIME-13-005 (Wave 0)) before starting and report status in module TASKS.md.
- Team Docs Guild: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `docs/TASKS.md`. Focus on DOCS-RUNTIME-17-004 (TODO). Confirm prerequisites (internal: DEVOPS-REL-17-002 (Wave 2), SCANNER-EMIT-17-701 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3)) before starting and report status in module TASKS.md.
- Team Excititor Connectors Stella: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md`. Focus on EXCITITOR-CONN-STELLA-07-002 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-STELLA-07-001 (Wave 3)) before starting and report status in module TASKS.md.
- Team Notify Connectors Guild: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `src/StellaOps.Notify.Connectors.Email/TASKS.md`, `src/StellaOps.Notify.Connectors.Slack/TASKS.md`, `src/StellaOps.Notify.Connectors.Teams/TASKS.md`, `src/StellaOps.Notify.Connectors.Webhook/TASKS.md`. Focus on NOTIFY-CONN-SLACK-15-501 (TODO), NOTIFY-CONN-TEAMS-15-601 (TODO), NOTIFY-CONN-EMAIL-15-701 (TODO), NOTIFY-CONN-WEBHOOK-15-801 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-303 (Wave 3)) before starting and report status in module TASKS.md.
@@ -169,10 +169,10 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- Path: `src/StellaOps.Web/TASKS.md`
2. [DONE 2025-10-21] WEB1.TRIVY-SETTINGS-TESTS — Add headless UI test run (`ng test --watch=false`) and document prerequisites once Angular tooling is chained up.
• Prereqs: WEB1.TRIVY-SETTINGS
• Current: DONE (2025-10-21) ChromeHeadless launcher + README updates merged; awaiting dependency hardening follow-up (WEB1.DEPS-13-001).
3. [TODO] WEB1.DEPS-13-001 — Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs).
• Current: DONE (2025-10-21) ChromeHeadless launcher + README updates merged; dependency hardening completed via WEB1.DEPS-13-001.
3. [DONE (2025-10-21)] WEB1.DEPS-13-001 — Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs).
• Prereqs: WEB1.TRIVY-SETTINGS-TESTS
• Current: TODO Capture deterministic lockfile flow, cache Puppeteer downloads, and validate `npm test` from clean checkout in air-gapped mode.
• Current: DONE (2025-10-21) Lockfile generated via `npm ci`, Chromium auto-detection/verification scripts added, and deterministic install guide published for offline runners.
- **Sprint 1** · Developer Tooling
- Team: DevEx/CLI
- Path: `src/StellaOps.Cli/TASKS.md`
@@ -311,9 +311,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 10** · Backlog
- Team: TBD
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-302C — Surface script metadata (postinstall/preinstall) and policy hints; emit telemetry counters and evidence records.
1. [DONE 2025-10-19] SCANNER-ANALYZERS-LANG-10-302C — Surface script metadata (postinstall/preinstall) and policy hints; emit telemetry counters and evidence records.
• Prereqs: SCANNER-ANALYZERS-LANG-10-302B (external/completed)
• Current: TODO
• Current: DONE — Telemetry counter wired, lifecycle script evidence emitted; see Node analyzer fixtures.
- **Sprint 10** · Scanner Analyzers & SBOM
- Team: Diff Guild
- Path: `src/StellaOps.Scanner.Diff/TASKS.md`
@@ -375,15 +375,15 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
• Prereqs: —
• Current: TODO
- Path: `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-301 — Java analyzer emitting `pkg:maven` with provenance.
1. [DONE 2025-10-19] SCANNER-ANALYZERS-LANG-10-301 — Java analyzer emitting `pkg:maven` with provenance.
• Prereqs: —
• Current: TODO
2. [TODO] SCANNER-ANALYZERS-LANG-10-307 — Shared language evidence helpers + usage flag propagation.
• Current: DONE — Java analyzer shipped with deterministic fixtures.
2. [DONE 2025-10-19] SCANNER-ANALYZERS-LANG-10-307 — Shared language evidence helpers + usage flag propagation.
• Prereqs: —
• Current: TODO
3. [TODO] SCANNER-ANALYZERS-LANG-10-308 — Determinism + fixture harness for language analyzers.
• Current: DONE — Shared helpers live under Lang.Core and are consumed by Java/Node analyzers.
3. [DONE 2025-10-19] SCANNER-ANALYZERS-LANG-10-308 — Determinism + fixture harness for language analyzers.
• Prereqs: —
• Current: TODO
• Current: DONE — Determinism harness + fixtures checked in; CI guard active.
- **Sprint 11** · Signing Chain Bring-up
- Team: Attestor Guild
- Path: `src/StellaOps.Attestor/TASKS.md`
@@ -494,6 +494,11 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
3. [TODO] DEVOPS-LAUNCH-18-001 - Production launch cutover rehearsal and runbook publication.
• Prereqs: DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900
• Current: TODO
- Team: Offline Kit Guild, UX Specialist
- Path: `ops/offline-kit/TASKS.md`
1. [TODO] DEVOPS-OFFLINE-18-003 — Capture Angular workspace npm cache + Chromium bundle for Offline Kit distribution and document refresh cadence.
• Prereqs: DEVOPS-OFFLINE-14-002 (Wave 2)
• Current: TODO
## Wave 1 — 45 task(s) ready after Wave 0
- **Sprint 6** · Excititor Ingest & Formats
@@ -531,21 +536,21 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 10** · Backlog
- Team: TBD
- Path: `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-305A — Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components.
1. [DONE 2025-10-22] SCANNER-ANALYZERS-LANG-10-305A — Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: TODO
• Current: DONE — RID-aware deps/runtimeconfig parser emitting deterministic NuGet components with tests landed.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-304A — Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence.
1. [DONE 2025-10-22] SCANNER-ANALYZERS-LANG-10-304A — Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: TODO
• Current: DONE Varint build-info decoder implemented with fixtures and determinism harness coverage.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-307N — Integrate shared helpers for license/licence evidence, canonical JSON serialization, and usage flag propagation.
1. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-307N — Integrate shared helpers for license/licence evidence, canonical JSON serialization, and usage flag propagation.
• Prereqs: SCANNER-ANALYZERS-LANG-10-302C (Wave 0)
• Current: TODO
• Current: DONE — Node analyzer now reuses shared metadata/evidence helpers.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-303A — STREAM-based parser for `*.dist-info` (`METADATA`, `WHEEL`, `entry_points.txt`) with normalization + evidence capture.
1. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-303A — STREAM-based parser for `*.dist-info` (`METADATA`, `WHEEL`, `entry_points.txt`) with normalization + evidence capture.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: TODO
• Current: DONE — Python analyzer ingests METADATA/WHEEL/entry_points with deterministic ordering and UTF-8 normalization. Fixtures updated (`simple-venv`).
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-306A — Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
@@ -558,24 +563,24 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
• Current: TODO
- Team: Language Analyzer Guild
- Path: `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md`
1. [DOING] SCANNER-ANALYZERS-LANG-10-309 — Package language analyzers as restart-time plug-ins (manifest + host registration).
1. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-309 — Package language analyzers as restart-time plug-ins (manifest + host registration).
• Prereqs: SCANNER-ANALYZERS-LANG-10-301 (Wave 0)
• Current: DOING (2025-10-19)
• Current: DONE — Manifest published under `plugins/scanner/analyzers/lang/`, Worker loader wired, integration tests updated.
2. [TODO] SCANNER-ANALYZERS-LANG-10-306 — Rust analyzer detecting crate provenance or falling back to `bin:{sha256}`.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: TODO
3. [DOING] SCANNER-ANALYZERS-LANG-10-302 — Node analyzer resolving workspaces/symlinks into `pkg:npm` identities.
3. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-302 — Node analyzer resolving workspaces/symlinks into `pkg:npm` identities.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: DOING (2025-10-19)
4. [TODO] SCANNER-ANALYZERS-LANG-10-304 — Go analyzer leveraging buildinfo for `pkg:golang` components.
• Current: DONE — Workspace/symlink coverage validated via determinism fixtures; metrics + lifecycle script evidence landed.
4. [DOING 2025-10-22] SCANNER-ANALYZERS-LANG-10-304 — Go analyzer leveraging buildinfo for `pkg:golang` components.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: TODO
5. [TODO] SCANNER-ANALYZERS-LANG-10-305 — .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants.
5. [DOING 2025-10-22] SCANNER-ANALYZERS-LANG-10-305 — .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: TODO
6. [TODO] SCANNER-ANALYZERS-LANG-10-303 — Python analyzer consuming `*.dist-info` metadata and RECORD hashes.
• Current: DOING — Implementing initial deps/runtimeconfig parsing for RID-aware components.
6. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-303 — Python analyzer consuming `*.dist-info` metadata and RECORD hashes.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0)
• Current: TODO
• Current: DONE — Dist-info parser, RECORD verifier, editable install metadata, and entrypoint usage hints shipped with deterministic fixture/tests.
- **Sprint 11** · UI Integration
- Team: UI Guild
- Path: `src/StellaOps.UI/TASKS.md`
@@ -604,7 +609,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
• Current: TODO Build Spectre test harness exercising `runtime policy test` against a stubbed backend to lock output shape (table + `--json`) and guard regressions. Integrate into `dotnet test` suite.
- Team: UX Specialist, Angular Eng, DevEx
- Path: `src/StellaOps.Web/TASKS.md`
1. [TODO] WEB1.DEPS-13-001 — Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs).
1. [DONE (2025-10-21)] WEB1.DEPS-13-001 — Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs).
• Prereqs: WEB1.TRIVY-SETTINGS-TESTS (Wave 0)
• Current: TODO Capture deterministic lockfile flow, cache Puppeteer downloads, validate `npm test` from clean checkout offline, and update README.
- Team: UI Guild
@@ -720,17 +725,17 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
• Prereqs: SCANNER-ANALYZERS-LANG-10-305A (Wave 1)
• Current: TODO
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-304B — Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries.
1. [DONE 2025-10-22] SCANNER-ANALYZERS-LANG-10-304B — Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries.
• Prereqs: SCANNER-ANALYZERS-LANG-10-304A (Wave 1)
• Current: TODO
• Current: DONE — DWARF fallback parses vcs.* markers, cache reuses metadata keyed by file identity.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-308N — Author determinism harness + fixtures for Node analyzer; add benchmark suite.
1. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-308N — Author determinism harness + fixtures for Node analyzer; add benchmark suite.
• Prereqs: SCANNER-ANALYZERS-LANG-10-307N (Wave 1)
• Current: TODO
• Current: DONE — Harness + fixtures merged; benchmark CSV recorded under `bench/Scanner.Analyzers`.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-303B — RECORD hash verifier with chunked hashing, Zip64 support, and mismatch diagnostics.
1. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-303B — RECORD hash verifier with chunked hashing, Zip64 support, and mismatch diagnostics.
• Prereqs: SCANNER-ANALYZERS-LANG-10-303A (Wave 1)
• Current: TODO
• Current: DONE — Streaming SHA-256 verification with deterministic mismatch evidence; unsupported algorithms tracked; fixtures validated.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-306B — Implement heuristic classifier using ELF section names, symbol mangling, and `.comment` data for stripped binaries.
• Prereqs: SCANNER-ANALYZERS-LANG-10-306A (Wave 1)
@@ -855,13 +860,13 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
• Prereqs: SCANNER-ANALYZERS-LANG-10-304B (Wave 2)
• Current: TODO
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-309N — Package Node analyzer as restart-time plug-in (manifest, DI registration, Offline Kit notes).
1. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-309N — Package Node analyzer as restart-time plug-in (manifest, DI registration, Offline Kit notes).
• Prereqs: SCANNER-ANALYZERS-LANG-10-308N (Wave 2)
• Current: TODO
• Current: DONE — Manifest shipped, Worker catalog integration complete, Offline Kit docs updated.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-303C — Editable install + pip cache detection; integrate EntryTrace hints for runtime usage flags.
1. [DONE 2025-10-21] SCANNER-ANALYZERS-LANG-10-303C — Editable install + pip cache detection; integrate EntryTrace hints for runtime usage flags.
• Prereqs: SCANNER-ANALYZERS-LANG-10-303B (Wave 2)
• Current: TODO
• Current: DONE — `direct_url.json` editable insights surfaced; EntryTrace usage hints mark console scripts; deterministic fixture covers editable vs wheel installs.
- Path: `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`
1. [TODO] SCANNER-ANALYZERS-LANG-10-306C — Integrate binary hash fallback (`bin:{sha256}`) and tie into shared quiet provenance helpers.
• Prereqs: SCANNER-ANALYZERS-LANG-10-306B (Wave 2)
@@ -940,7 +945,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
- **Sprint 13** · UX & CLI Experience
- Team: DevEx/CLI
- Path: `src/StellaOps.Cli/TASKS.md`
1. [TODO] CLI-PLUGIN-13-007 — CLI-PLUGIN-13-007 Plugin packaging
1. [DONE 2025-10-22] CLI-PLUGIN-13-007 — CLI-PLUGIN-13-007 Plugin packaging
• Prereqs: CLI-RUNTIME-13-005 (Wave 0), CLI-OFFLINE-13-006 (Wave 3)
• Current: TODO Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).
- **Sprint 15** · Notify Foundations

View File

@@ -14,14 +14,14 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-206 | Determinism harness + fixtures for OS analyzers. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-207 | Package OS analyzers as restart-time plug-ins (manifest + host registration). |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-301 | Java analyzer emitting `pkg:maven` with provenance. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-302 | Node analyzer handling workspaces/symlinks emitting `pkg:npm`. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-303 | Python analyzer reading `*.dist-info`, RECORD hashes, entry points. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-304 | Go analyzer leveraging buildinfo for `pkg:golang` components. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-305 | .NET analyzer parsing `*.deps.json`, assembly metadata, RID variants. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-302 | Node analyzer handling workspaces/symlinks emitting `pkg:npm`. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-303 | Python analyzer reading `*.dist-info`, RECORD hashes, entry points. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-304 | Go analyzer leveraging buildinfo for `pkg:golang` components. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-305 | .NET analyzer parsing `*.deps.json`, assembly metadata, RID variants. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-306 | Rust analyzer detecting crates or falling back to `bin:{sha256}`. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Shared language evidence helpers + usage flag propagation. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-308 | Determinism + fixture harness for language analyzers. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-309 | Package language analyzers as restart-time plug-ins (manifest + host registration). |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Shared language evidence helpers + usage flag propagation. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-308 | Determinism + fixture harness for language analyzers. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-309 | Package language analyzers as restart-time plug-ins (manifest + host registration). |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401 | POSIX shell AST parser with deterministic output. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Command resolution across layered rootfs with evidence attribution. |
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Interpreter tracing for shell wrappers to Python/Node/Java launchers. |
@@ -68,8 +68,8 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCHED-13-005 | Scheduler panel: schedules CRUD, run history, dry-run preview. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | DOING (2025-10-19) | UI Guild | UI-NOTIFY-13-006 | Notify panel: channels/rules CRUD, deliveries view, test send. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-RUNTIME-13-005 | Add runtime policy test verbs that consume `/policy/runtime` and display verdicts. |
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-PLUGIN-13-007 | Package non-core CLI verbs as restart-time plug-ins (manifest + loader tests). |
| Sprint 13 | UX & CLI Experience | src/StellaOps.Web/TASKS.md | TODO | UX Specialist, Angular Eng, DevEx | WEB1.DEPS-13-001 | Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs). |
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | DONE (2025-10-22) | DevEx/CLI | CLI-PLUGIN-13-007 | Package non-core CLI verbs as restart-time plug-ins (manifest + loader tests). |
| Sprint 13 | UX & CLI Experience | src/StellaOps.Web/TASKS.md | DONE (2025-10-21) | UX Specialist, Angular Eng, DevEx | WEB1.DEPS-13-001 | Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs). |
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-001 | Wire up .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. |
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-14-001 | Deterministic build/release pipeline with SBOM/provenance, signing, and manifest generation. |
| Sprint 14 | Release & Offline Ops | ops/offline-kit/TASKS.md | TODO | Offline Kit Guild | DEVOPS-OFFLINE-14-002 | Offline kit packaging workflow with integrity verification and documentation. |
@@ -127,3 +127,4 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-17-002 | Ship stripped debug artifacts organised by build-id within release/offline kits. |
| Sprint 17 | Symbol Intelligence & Forensics | docs/TASKS.md | TODO | Docs Guild | DOCS-RUNTIME-17-004 | Document build-id workflows for SBOMs, runtime events, and debug-store usage. |
| Sprint 18 | Launch Readiness | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-LAUNCH-18-001 | Production launch cutover rehearsal and runbook publication (blocked on implementation sign-off and environment setup). |
| Sprint 18 | Launch Readiness | ops/offline-kit/TASKS.md | TODO | Offline Kit Guild, UX Specialist | DEVOPS-OFFLINE-18-003 | Capture Angular workspace npm cache + Chromium bundle for Offline Kit distribution and document refresh cadence. |

View File

@@ -17,9 +17,12 @@ completely isolated network:
| **Provenance** | Cosign signature, SPDX 2.3 SBOM, intoto SLSA attestation |
| **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. |
| **Delta patches** | Daily diff bundles keep size \<350MB |
| **Scanner plug-ins** | OS analyzers and the Node.js language analyzer packaged under `plugins/scanner/analyzers/**` with manifests so Workers load deterministically offline. |
**RU BDU note:** ship the official Russian Trusted Root/Sub CA bundle (`certificates/russian_trusted_bundle.pem`) inside the kit so `concelier:httpClients:source.bdu:trustedRootPaths` can resolve it when the service runs in an airgapped network. Drop the most recent `vulxml.zip` alongside the kit if operators need a cold-start cache.
**Language analyzers:** the kit now carries the restart-only Node.js analyzer plug-in (`plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Node/`). Drop the directory alongside Worker binaries so the unified plug-in catalog can load it without outbound fetches; upcoming Python/Go/.NET/Rust plug-ins will follow the same layout.
*Scanner core:* C# 12 on **.NET{{ dotnet }}**.
*Imports are idempotent and atomic — no service downtime.*

View File

@@ -37,7 +37,7 @@ src/
**Language/runtime**: .NET 10 **Native AOT** for speed/startup; Linux builds use **musl** static when possible.
**Plug-in verbs.** Non-core verbs (Excititor, runtime helpers, future integrations) ship as restart-time plug-ins under `plugins/cli/**` with manifest descriptors. The launcher loads plug-ins on startup; hot reloading is intentionally unsupported.
**Plug-in verbs.** Non-core verbs (Excititor, runtime helpers, future integrations) ship as restart-time plug-ins under `plugins/cli/**` with manifest descriptors. The launcher loads plug-ins on startup; hot reloading is intentionally unsupported. The inaugural bundle, `StellaOps.Cli.Plugins.NonCore`, packages the Excititor, runtime, and offline-kit command groups and publishes its manifest at `plugins/cli/StellaOps.Cli.Plugins.NonCore/`.
**OS targets**: linuxx64/arm64, windowsx64/arm64, macOSx64/arm64.

View File

@@ -1,5 +1,6 @@
# Offline Kit Task Board
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| DEVOPS-OFFLINE-14-002 | TODO | Offline Kit Guild | DEVOPS-REL-14-001 | Build offline kit packaging workflow (artifact bundling, manifest generation, signature verification). | Offline tarball generated with manifest + checksums + signatures; import script verifies integrity; docs updated. |
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| DEVOPS-OFFLINE-14-002 | TODO | Offline Kit Guild | DEVOPS-REL-14-001 | Build offline kit packaging workflow (artifact bundling, manifest generation, signature verification). | Offline tarball generated with manifest + checksums + signatures; import script verifies integrity; docs updated. |
| DEVOPS-OFFLINE-18-003 | TODO | Offline Kit Guild, UX Specialist | DEVOPS-OFFLINE-14-002 | Capture Angular workspace npm cache + Chromium bundle in Offline Kit (`out/offline-kit/web/`) and document refresh cadence. | Web cache directory added to kit manifest; documentation updated with `npm run ci:install`/`verify:chromium` workflow; periodic refresh SOP recorded in Offline Kit guide. |

View File

@@ -0,0 +1,21 @@
{
"schemaVersion": "1.0",
"id": "stellaops.cli.plugins.noncore",
"displayName": "StellaOps CLI Non-core Verbs",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Cli.Plugins.NonCore.dll",
"typeName": "StellaOps.Cli.Plugins.NonCore.NonCoreCliCommandModule"
},
"capabilities": [
"cli",
"excititor",
"runtime-policy",
"offline-kit"
],
"metadata": {
"org.stellaops.restart.required": "true"
}
}

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.dotnet",
"displayName": "StellaOps .NET Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.DotNet.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.DotNet.DotNetAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"dotnet",
"nuget"
],
"metadata": {
"org.stellaops.analyzer.language": "dotnet",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.go",
"displayName": "StellaOps Go Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Go.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Go.GoAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"golang",
"go"
],
"metadata": {
"org.stellaops.analyzer.language": "go",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}

View File

@@ -0,0 +1,22 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.node",
"displayName": "StellaOps Node.js Analyzer",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Node.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Node.NodeAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"node",
"npm"
],
"metadata": {
"org.stellaops.analyzer.language": "node",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true"
}
}

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.python",
"displayName": "StellaOps Python Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Python.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Python.PythonAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"python",
"pypi"
],
"metadata": {
"org.stellaops.analyzer.language": "python",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}

View File

@@ -0,0 +1,416 @@
using System;
using System.CommandLine;
using System.Threading;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
namespace StellaOps.Cli.Plugins.NonCore;
public sealed class NonCoreCliCommandModule : ICliCommandModule
{
public string Name => "stellaops.cli.plugins.noncore";
public bool IsAvailable(IServiceProvider services) => true;
public void RegisterCommands(
RootCommand root,
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(verboseOption);
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken));
root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken));
}
private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows.");
var init = new Command("init", "Initialize Excititor ingest state.");
var initProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to initialize.",
Arity = ArgumentArity.ZeroOrMore
};
var resumeOption = new Option<bool>("--resume")
{
Description = "Resume ingest from the last persisted checkpoint instead of starting fresh."
};
init.Add(initProviders);
init.Add(resumeOption);
init.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>();
var resume = parseResult.GetValue(resumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken);
});
var pull = new Command("pull", "Trigger Excititor ingest for configured providers.");
var pullProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to ingest.",
Arity = ArgumentArity.ZeroOrMore
};
var sinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to begin the ingest window."
};
var windowOption = new Option<TimeSpan?>("--window")
{
Description = "Optional window duration (e.g. 24:00:00)."
};
var forceOption = new Option<bool>("--force")
{
Description = "Force ingestion even if the backend reports no pending work."
};
pull.Add(pullProviders);
pull.Add(sinceOption);
pull.Add(windowOption);
pull.Add(forceOption);
pull.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>();
var since = parseResult.GetValue(sinceOption);
var window = parseResult.GetValue(windowOption);
var force = parseResult.GetValue(forceOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken);
});
var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token.");
var resumeProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to resume.",
Arity = ArgumentArity.ZeroOrMore
};
var checkpointOption = new Option<string?>("--checkpoint")
{
Description = "Optional checkpoint identifier to resume from."
};
resume.Add(resumeProviders);
resume.Add(checkpointOption);
resume.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>();
var checkpoint = parseResult.GetValue(checkpointOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken);
});
var list = new Command("list-providers", "List Excititor providers and their ingest status.");
var includeDisabledOption = new Option<bool>("--include-disabled")
{
Description = "Include disabled providers in the listing."
};
list.Add(includeDisabledOption);
list.SetAction((parseResult, _) =>
{
var includeDisabled = parseResult.GetValue(includeDisabledOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken);
});
var export = new Command("export", "Trigger Excititor export generation.");
var formatOption = new Option<string>("--format")
{
Description = "Export format (e.g. openvex, json)."
};
var exportDeltaOption = new Option<bool>("--delta")
{
Description = "Request a delta export when supported."
};
var exportScopeOption = new Option<string?>("--scope")
{
Description = "Optional policy scope or tenant identifier."
};
var exportSinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to restrict export contents."
};
var exportProviderOption = new Option<string?>("--provider")
{
Description = "Optional provider identifier when requesting targeted exports."
};
var exportOutputOption = new Option<string?>("--output")
{
Description = "Optional path to download the export artifact."
};
export.Add(formatOption);
export.Add(exportDeltaOption);
export.Add(exportScopeOption);
export.Add(exportSinceOption);
export.Add(exportProviderOption);
export.Add(exportOutputOption);
export.SetAction((parseResult, _) =>
{
var format = parseResult.GetValue(formatOption) ?? "openvex";
var delta = parseResult.GetValue(exportDeltaOption);
var scope = parseResult.GetValue(exportScopeOption);
var since = parseResult.GetValue(exportSinceOption);
var provider = parseResult.GetValue(exportProviderOption);
var output = parseResult.GetValue(exportOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
});
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
{
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
};
var backfillForceOption = new Option<bool>("--force")
{
Description = "Reprocess documents even if statements already exist."
};
var backfillBatchSizeOption = new Option<int>("--batch-size")
{
Description = "Number of raw documents to fetch per batch (default 100)."
};
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
{
Description = "Optional maximum number of raw documents to process."
};
backfill.Add(backfillRetrievedSinceOption);
backfill.Add(backfillForceOption);
backfill.Add(backfillBatchSizeOption);
backfill.Add(backfillMaxDocumentsOption);
backfill.SetAction((parseResult, _) =>
{
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
var force = parseResult.GetValue(backfillForceOption);
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
if (batchSize <= 0)
{
batchSize = 100;
}
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorBackfillStatementsAsync(
services,
retrievedSince,
force,
batchSize,
maxDocuments,
verbose,
cancellationToken);
});
var verify = new Command("verify", "Verify Excititor exports or attestations.");
var exportIdOption = new Option<string?>("--export-id")
{
Description = "Export identifier to verify."
};
var digestOption = new Option<string?>("--digest")
{
Description = "Expected digest for the export or attestation."
};
var attestationOption = new Option<string?>("--attestation")
{
Description = "Path to a local attestation file to verify (base64 content will be uploaded)."
};
verify.Add(exportIdOption);
verify.Add(digestOption);
verify.Add(attestationOption);
verify.SetAction((parseResult, _) =>
{
var exportId = parseResult.GetValue(exportIdOption);
var digest = parseResult.GetValue(digestOption);
var attestation = parseResult.GetValue(attestationOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken);
});
var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories.");
var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to reconcile.",
Arity = ArgumentArity.ZeroOrMore
};
var maxAgeOption = new Option<TimeSpan?>("--max-age")
{
Description = "Optional maximum age window (e.g. 7.00:00:00)."
};
reconcile.Add(reconcileProviders);
reconcile.Add(maxAgeOption);
reconcile.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>();
var maxAge = parseResult.GetValue(maxAgeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken);
});
excititor.Add(init);
excititor.Add(pull);
excititor.Add(resume);
excititor.Add(list);
excititor.Add(export);
excititor.Add(backfill);
excititor.Add(verify);
excititor.Add(reconcile);
return excititor;
}
private static Command BuildRuntimeCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var runtime = new Command("runtime", "Interact with runtime admission policy APIs.");
var policy = new Command("policy", "Runtime policy operations.");
var test = new Command("test", "Evaluate runtime policy decisions for image digests.");
var namespaceOption = new Option<string?>("--namespace", new[] { "--ns" })
{
Description = "Namespace or logical scope for the evaluation."
};
var imageOption = new Option<string[]>("--image", new[] { "-i", "--images" })
{
Description = "Image digests to evaluate (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var fileOption = new Option<string?>("--file", new[] { "-f" })
{
Description = "Path to a file containing image digests (one per line)."
};
var labelOption = new Option<string[]>("--label", new[] { "-l", "--labels" })
{
Description = "Pod labels in key=value format (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Emit the raw JSON response."
};
test.Add(namespaceOption);
test.Add(imageOption);
test.Add(fileOption);
test.Add(labelOption);
test.Add(jsonOption);
test.SetAction((parseResult, _) =>
{
var nsValue = parseResult.GetValue(namespaceOption);
var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>();
var file = parseResult.GetValue(fileOption);
var labels = parseResult.GetValue(labelOption) ?? Array.Empty<string>();
var outputJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleRuntimePolicyTestAsync(
services,
nsValue,
images,
file,
labels,
outputJson,
verbose,
cancellationToken);
});
policy.Add(test);
runtime.Add(policy);
return runtime;
}
private static Command BuildOfflineCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var offline = new Command("offline", "Offline kit workflows and utilities.");
var kit = new Command("kit", "Manage offline kit bundles.");
var pull = new Command("pull", "Download the latest offline kit bundle.");
var bundleIdOption = new Option<string?>("--bundle-id")
{
Description = "Optional bundle identifier. Defaults to the latest available."
};
var destinationOption = new Option<string?>("--destination")
{
Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)."
};
var overwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite existing files even if checksums match."
};
var noResumeOption = new Option<bool>("--no-resume")
{
Description = "Disable resuming partial downloads."
};
pull.Add(bundleIdOption);
pull.Add(destinationOption);
pull.Add(overwriteOption);
pull.Add(noResumeOption);
pull.SetAction((parseResult, _) =>
{
var bundleId = parseResult.GetValue(bundleIdOption);
var destination = parseResult.GetValue(destinationOption);
var overwrite = parseResult.GetValue(overwriteOption);
var resume = !parseResult.GetValue(noResumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken);
});
var import = new Command("import", "Upload an offline kit bundle to the backend.");
var bundleArgument = new Argument<string>("bundle")
{
Description = "Path to the offline kit tarball (.tgz)."
};
var manifestOption = new Option<string?>("--manifest")
{
Description = "Offline manifest JSON path (defaults to metadata or sibling file)."
};
var bundleSignatureOption = new Option<string?>("--bundle-signature")
{
Description = "Detached signature for the offline bundle (e.g. .sig)."
};
var manifestSignatureOption = new Option<string?>("--manifest-signature")
{
Description = "Detached signature for the offline manifest (e.g. .jws)."
};
import.Add(bundleArgument);
import.Add(manifestOption);
import.Add(bundleSignatureOption);
import.Add(manifestSignatureOption);
import.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty;
var manifest = parseResult.GetValue(manifestOption);
var bundleSignature = parseResult.GetValue(bundleSignatureOption);
var manifestSignature = parseResult.GetValue(manifestSignatureOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken);
});
var status = new Command("status", "Display offline kit installation status.");
var jsonOption = new Option<bool>("--json")
{
Description = "Emit status as JSON."
};
status.Add(jsonOption);
status.SetAction((parseResult, _) =>
{
var asJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken);
});
kit.Add(pull);
kit.Add(import);
kit.Add(status);
offline.Add(kit);
return offline;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\\..\\plugins\\cli\\StellaOps.Cli.Plugins.NonCore\\'))</PluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" />
</ItemGroup>
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<MakeDir Directories="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
DestinationFolder="$(PluginOutputDirectory)"
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
</Target>
</Project>

View File

@@ -0,0 +1,41 @@
using System;
using System.CommandLine;
using System.IO;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
using Xunit;
namespace StellaOps.Cli.Tests.Plugins;
public sealed class CliCommandModuleLoaderTests
{
[Fact]
public void RegisterModules_LoadsNonCoreCommandsFromPlugin()
{
var options = new StellaOpsCliOptions();
var repoRoot = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
options.Plugins.BaseDirectory = repoRoot;
options.Plugins.Directory = "plugins/cli";
var services = new ServiceCollection()
.AddSingleton(options)
.BuildServiceProvider();
var logger = NullLoggerFactory.Instance.CreateLogger<CliCommandModuleLoader>();
var loader = new CliCommandModuleLoader(services, options, logger);
var root = new RootCommand();
var verbose = new Option<bool>("--verbose");
loader.RegisterModules(root, verbose, CancellationToken.None);
Assert.Contains(root.Children, command => string.Equals(command.Name, "excititor", StringComparison.Ordinal));
Assert.Contains(root.Children, command => string.Equals(command.Name, "runtime", StringComparison.Ordinal));
Assert.Contains(root.Children, command => string.Equals(command.Name, "offline", StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.Cli.Plugins;
using Xunit;
namespace StellaOps.Cli.Tests.Plugins;
public sealed class RestartOnlyCliPluginGuardTests
{
[Fact]
public void EnsureRegistrationAllowed_AllowsDuringStartup()
{
var guard = new RestartOnlyCliPluginGuard();
guard.EnsureRegistrationAllowed("./plugins/sample.dll");
guard.Seal();
// Re-registering known plug-ins after sealing should succeed.
guard.EnsureRegistrationAllowed("./plugins/sample.dll");
Assert.True(guard.IsSealed);
Assert.Single(guard.KnownPlugins);
}
[Fact]
public void EnsureRegistrationAllowed_ThrowsForUnknownAfterSeal()
{
var guard = new RestartOnlyCliPluginGuard();
guard.Seal();
Assert.Throws<InvalidOperationException>(() => guard.EnsureRegistrationAllowed("./plugins/new.dll"));
}
}

View File

@@ -1,19 +1,27 @@
using System;
using System.CommandLine;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
namespace StellaOps.Cli.Commands;
internal static class CommandFactory
{
public static RootCommand Create(IServiceProvider services, StellaOpsCliOptions options, CancellationToken cancellationToken)
{
var verboseOption = new Option<bool>("--verbose", new[] { "-v" })
{
Description = "Enable verbose logging output."
};
internal static class CommandFactory
{
public static RootCommand Create(
IServiceProvider services,
StellaOpsCliOptions options,
CancellationToken cancellationToken,
ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(loggerFactory);
var verboseOption = new Option<bool>("--verbose", new[] { "-v" })
{
Description = "Enable verbose logging output."
};
var root = new RootCommand("StellaOps command-line interface")
{
@@ -24,12 +32,13 @@ internal static class CommandFactory
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
pluginLoader.RegisterModules(root, verboseOption, cancellationToken);
return root;
}
@@ -227,300 +236,6 @@ internal static class CommandFactory
return db;
}
private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows.");
var init = new Command("init", "Initialize Excititor ingest state.");
var initProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to initialize.",
Arity = ArgumentArity.ZeroOrMore
};
var resumeOption = new Option<bool>("--resume")
{
Description = "Resume ingest from the last persisted checkpoint instead of starting fresh."
};
init.Add(initProviders);
init.Add(resumeOption);
init.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>();
var resume = parseResult.GetValue(resumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken);
});
var pull = new Command("pull", "Trigger Excititor ingest for configured providers.");
var pullProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to ingest.",
Arity = ArgumentArity.ZeroOrMore
};
var sinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to begin the ingest window."
};
var windowOption = new Option<TimeSpan?>("--window")
{
Description = "Optional window duration (e.g. 24:00:00)."
};
var forceOption = new Option<bool>("--force")
{
Description = "Force ingestion even if the backend reports no pending work."
};
pull.Add(pullProviders);
pull.Add(sinceOption);
pull.Add(windowOption);
pull.Add(forceOption);
pull.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>();
var since = parseResult.GetValue(sinceOption);
var window = parseResult.GetValue(windowOption);
var force = parseResult.GetValue(forceOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken);
});
var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token.");
var resumeProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to resume.",
Arity = ArgumentArity.ZeroOrMore
};
var checkpointOption = new Option<string?>("--checkpoint")
{
Description = "Optional checkpoint identifier to resume from."
};
resume.Add(resumeProviders);
resume.Add(checkpointOption);
resume.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>();
var checkpoint = parseResult.GetValue(checkpointOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken);
});
var list = new Command("list-providers", "List Excititor providers and their ingest status.");
var includeDisabledOption = new Option<bool>("--include-disabled")
{
Description = "Include disabled providers in the listing."
};
list.Add(includeDisabledOption);
list.SetAction((parseResult, _) =>
{
var includeDisabled = parseResult.GetValue(includeDisabledOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken);
});
var export = new Command("export", "Trigger Excititor export generation.");
var formatOption = new Option<string>("--format")
{
Description = "Export format (e.g. openvex, json)."
};
var exportDeltaOption = new Option<bool>("--delta")
{
Description = "Request a delta export when supported."
};
var exportScopeOption = new Option<string?>("--scope")
{
Description = "Optional policy scope or tenant identifier."
};
var exportSinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to restrict export contents."
};
var exportProviderOption = new Option<string?>("--provider")
{
Description = "Optional provider identifier when requesting targeted exports."
};
var exportOutputOption = new Option<string?>("--output")
{
Description = "Optional path to download the export artifact."
};
export.Add(formatOption);
export.Add(exportDeltaOption);
export.Add(exportScopeOption);
export.Add(exportSinceOption);
export.Add(exportProviderOption);
export.Add(exportOutputOption);
export.SetAction((parseResult, _) =>
{
var format = parseResult.GetValue(formatOption) ?? "openvex";
var delta = parseResult.GetValue(exportDeltaOption);
var scope = parseResult.GetValue(exportScopeOption);
var since = parseResult.GetValue(exportSinceOption);
var provider = parseResult.GetValue(exportProviderOption);
var output = parseResult.GetValue(exportOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
});
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
{
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
};
var backfillForceOption = new Option<bool>("--force")
{
Description = "Reprocess documents even if statements already exist."
};
var backfillBatchSizeOption = new Option<int>("--batch-size")
{
Description = "Number of raw documents to fetch per batch (default 100)."
};
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
{
Description = "Optional maximum number of raw documents to process."
};
backfill.Add(backfillRetrievedSinceOption);
backfill.Add(backfillForceOption);
backfill.Add(backfillBatchSizeOption);
backfill.Add(backfillMaxDocumentsOption);
backfill.SetAction((parseResult, _) =>
{
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
var force = parseResult.GetValue(backfillForceOption);
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
if (batchSize <= 0)
{
batchSize = 100;
}
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorBackfillStatementsAsync(
services,
retrievedSince,
force,
batchSize,
maxDocuments,
verbose,
cancellationToken);
});
var verify = new Command("verify", "Verify Excititor exports or attestations.");
var exportIdOption = new Option<string?>("--export-id")
{
Description = "Export identifier to verify."
};
var digestOption = new Option<string?>("--digest")
{
Description = "Expected digest for the export or attestation."
};
var attestationOption = new Option<string?>("--attestation")
{
Description = "Path to a local attestation file to verify (base64 content will be uploaded)."
};
verify.Add(exportIdOption);
verify.Add(digestOption);
verify.Add(attestationOption);
verify.SetAction((parseResult, _) =>
{
var exportId = parseResult.GetValue(exportIdOption);
var digest = parseResult.GetValue(digestOption);
var attestation = parseResult.GetValue(attestationOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken);
});
var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories.");
var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to reconcile.",
Arity = ArgumentArity.ZeroOrMore
};
var maxAgeOption = new Option<TimeSpan?>("--max-age")
{
Description = "Optional maximum age window (e.g. 7.00:00:00)."
};
reconcile.Add(reconcileProviders);
reconcile.Add(maxAgeOption);
reconcile.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>();
var maxAge = parseResult.GetValue(maxAgeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken);
});
excititor.Add(init);
excititor.Add(pull);
excititor.Add(resume);
excititor.Add(list);
excititor.Add(export);
excititor.Add(backfill);
excititor.Add(verify);
excititor.Add(reconcile);
return excititor;
}
private static Command BuildRuntimeCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var runtime = new Command("runtime", "Interact with runtime admission policy APIs.");
var policy = new Command("policy", "Runtime policy operations.");
var test = new Command("test", "Evaluate runtime policy decisions for image digests.");
var namespaceOption = new Option<string?>("--namespace", new[] { "--ns" })
{
Description = "Namespace or logical scope for the evaluation."
};
var imageOption = new Option<string[]>("--image", new[] { "-i", "--images" })
{
Description = "Image digests to evaluate (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var fileOption = new Option<string?>("--file", new[] { "-f" })
{
Description = "Path to a file containing image digests (one per line)."
};
var labelOption = new Option<string[]>("--label", new[] { "-l", "--labels" })
{
Description = "Pod labels in key=value format (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Emit the raw JSON response."
};
test.Add(namespaceOption);
test.Add(imageOption);
test.Add(fileOption);
test.Add(labelOption);
test.Add(jsonOption);
test.SetAction((parseResult, _) =>
{
var nsValue = parseResult.GetValue(namespaceOption);
var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>();
var file = parseResult.GetValue(fileOption);
var labels = parseResult.GetValue(labelOption) ?? Array.Empty<string>();
var outputJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleRuntimePolicyTestAsync(
services,
nsValue,
images,
file,
labels,
outputJson,
verbose,
cancellationToken);
});
policy.Add(test);
runtime.Add(policy);
return runtime;
}
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
@@ -607,97 +322,6 @@ internal static class CommandFactory
return auth;
}
private static Command BuildOfflineCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var offline = new Command("offline", "Offline kit workflows and utilities.");
var kit = new Command("kit", "Manage offline kit bundles.");
var pull = new Command("pull", "Download the latest offline kit bundle.");
var bundleIdOption = new Option<string?>("--bundle-id")
{
Description = "Optional bundle identifier. Defaults to the latest available."
};
var destinationOption = new Option<string?>("--destination")
{
Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)."
};
var overwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite existing files even if checksums match."
};
var noResumeOption = new Option<bool>("--no-resume")
{
Description = "Disable resuming partial downloads."
};
pull.Add(bundleIdOption);
pull.Add(destinationOption);
pull.Add(overwriteOption);
pull.Add(noResumeOption);
pull.SetAction((parseResult, _) =>
{
var bundleId = parseResult.GetValue(bundleIdOption);
var destination = parseResult.GetValue(destinationOption);
var overwrite = parseResult.GetValue(overwriteOption);
var resume = !parseResult.GetValue(noResumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken);
});
var import = new Command("import", "Upload an offline kit bundle to the backend.");
var bundleArgument = new Argument<string>("bundle")
{
Description = "Path to the offline kit tarball (.tgz)."
};
var manifestOption = new Option<string?>("--manifest")
{
Description = "Offline manifest JSON path (defaults to metadata or sibling file)."
};
var bundleSignatureOption = new Option<string?>("--bundle-signature")
{
Description = "Detached signature for the offline bundle (e.g. .sig)."
};
var manifestSignatureOption = new Option<string?>("--manifest-signature")
{
Description = "Detached signature for the offline manifest (e.g. .jws)."
};
import.Add(bundleArgument);
import.Add(manifestOption);
import.Add(bundleSignatureOption);
import.Add(manifestSignatureOption);
import.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty;
var manifest = parseResult.GetValue(manifestOption);
var bundleSignature = parseResult.GetValue(bundleSignatureOption);
var manifestSignature = parseResult.GetValue(manifestSignatureOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken);
});
var status = new Command("status", "Display offline kit installation status.");
var jsonOption = new Option<bool>("--json")
{
Description = "Emit status as JSON."
};
status.Add(jsonOption);
status.SetAction((parseResult, _) =>
{
var asJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken);
});
kit.Add(pull);
kit.Add(import);
kit.Add(status);
offline.Add(kit);
return offline;
}
private static Command BuildConfigCommand(StellaOpsCliOptions options)
{
var config = new Command("config", "Inspect CLI configuration state.");

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Configuration;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
@@ -234,6 +235,93 @@ public static class CliBootstrapper
"Offline:MirrorUrl");
offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim();
cliOptions.Plugins ??= new StellaOpsCliPluginOptions();
var pluginOptions = cliOptions.Plugins;
pluginOptions.BaseDirectory = ResolveWithFallback(
pluginOptions.BaseDirectory,
configuration,
"STELLAOPS_CLI_PLUGIN_BASE_DIRECTORY",
"StellaOps:Plugins:BaseDirectory",
"Plugins:BaseDirectory");
pluginOptions.BaseDirectory = (pluginOptions.BaseDirectory ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(pluginOptions.BaseDirectory))
{
pluginOptions.BaseDirectory = AppContext.BaseDirectory;
}
pluginOptions.BaseDirectory = Path.GetFullPath(pluginOptions.BaseDirectory);
pluginOptions.Directory = ResolveWithFallback(
pluginOptions.Directory,
configuration,
"STELLAOPS_CLI_PLUGIN_DIRECTORY",
"StellaOps:Plugins:Directory",
"Plugins:Directory");
pluginOptions.Directory = (pluginOptions.Directory ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(pluginOptions.Directory))
{
pluginOptions.Directory = Path.Combine("plugins", "cli");
}
if (!Path.IsPathRooted(pluginOptions.Directory))
{
pluginOptions.Directory = Path.GetFullPath(Path.Combine(pluginOptions.BaseDirectory, pluginOptions.Directory));
}
else
{
pluginOptions.Directory = Path.GetFullPath(pluginOptions.Directory);
}
pluginOptions.ManifestSearchPattern = ResolveWithFallback(
pluginOptions.ManifestSearchPattern,
configuration,
"STELLAOPS_CLI_PLUGIN_MANIFEST_PATTERN",
"StellaOps:Plugins:ManifestSearchPattern",
"Plugins:ManifestSearchPattern");
pluginOptions.ManifestSearchPattern = (pluginOptions.ManifestSearchPattern ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern))
{
pluginOptions.ManifestSearchPattern = "*.manifest.json";
}
if (pluginOptions.SearchPatterns is null || pluginOptions.SearchPatterns.Count == 0)
{
pluginOptions.SearchPatterns = new List<string> { "StellaOps.Cli.Plugin.*.dll" };
}
else
{
pluginOptions.SearchPatterns = pluginOptions.SearchPatterns
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
.Select(pattern => pattern.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (pluginOptions.SearchPatterns.Count == 0)
{
pluginOptions.SearchPatterns.Add("StellaOps.Cli.Plugin.*.dll");
}
}
if (pluginOptions.PluginOrder is null)
{
pluginOptions.PluginOrder = new List<string>();
}
else
{
pluginOptions.PluginOrder = pluginOptions.PluginOrder
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
};
});

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using StellaOps.Auth.Abstractions;
using System.IO;
namespace StellaOps.Cli.Configuration;
@@ -25,6 +26,8 @@ public sealed class StellaOpsCliOptions
public StellaOpsCliAuthorityOptions Authority { get; set; } = new();
public StellaOpsCliOfflineOptions Offline { get; set; } = new();
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
}
public sealed class StellaOpsCliAuthorityOptions
@@ -63,3 +66,16 @@ public sealed class StellaOpsCliOfflineOptions
public string? MirrorUrl { get; set; }
}
public sealed class StellaOpsCliPluginOptions
{
public string BaseDirectory { get; set; } = string.Empty;
public string Directory { get; set; } = "plugins/cli";
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> PluginOrder { get; set; } = new List<string>();
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
}

View File

@@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Cli.Plugins;
internal sealed class CliCommandModuleLoader
{
private readonly IServiceProvider _services;
private readonly StellaOpsCliOptions _options;
private readonly ILogger<CliCommandModuleLoader> _logger;
private readonly RestartOnlyCliPluginGuard _guard = new();
private IReadOnlyList<ICliCommandModule> _modules = Array.Empty<ICliCommandModule>();
private bool _loaded;
public CliCommandModuleLoader(
IServiceProvider services,
StellaOpsCliOptions options,
ILogger<CliCommandModuleLoader> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyList<ICliCommandModule> LoadModules()
{
if (_loaded)
{
return _modules;
}
var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions();
var baseDirectory = ResolveBaseDirectory(pluginOptions);
var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory);
var searchPatterns = ResolveSearchPatterns(pluginOptions);
var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern)
? "*.manifest.json"
: pluginOptions.ManifestSearchPattern;
_logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory);
var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern);
IReadOnlyList<CliPluginManifest> manifests;
try
{
manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory);
manifests = Array.Empty<CliPluginManifest>();
}
if (manifests.Count == 0)
{
_logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory);
_loaded = true;
_guard.Seal();
_modules = Array.Empty<ICliCommandModule>();
return _modules;
}
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = true,
PrimaryPrefix = "StellaOps.Cli"
};
foreach (var pattern in searchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty<string>())
{
if (!string.IsNullOrWhiteSpace(ordered))
{
hostOptions.PluginOrder.Add(ordered);
}
}
var loadResult = PluginHost.LoadPlugins(hostOptions, _logger);
var assemblies = loadResult.Plugins.ToDictionary(
descriptor => Normalize(descriptor.AssemblyPath),
descriptor => descriptor.Assembly,
StringComparer.OrdinalIgnoreCase);
var modules = new List<ICliCommandModule>(manifests.Count);
foreach (var manifest in manifests)
{
try
{
var assemblyPath = ResolveAssemblyPath(manifest);
_guard.EnsureRegistrationAllowed(assemblyPath);
if (!assemblies.TryGetValue(assemblyPath, out var assembly))
{
if (!File.Exists(assemblyPath))
{
throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found.");
}
assembly = Assembly.LoadFrom(assemblyPath);
assemblies[assemblyPath] = assembly;
}
var module = CreateModule(assembly, manifest);
if (module is null)
{
continue;
}
modules.Add(module);
_logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id);
}
}
_modules = modules;
_loaded = true;
_guard.Seal();
return _modules;
}
public void RegisterModules(RootCommand root, Option<bool> verboseOption, CancellationToken cancellationToken)
{
if (root is null)
{
throw new ArgumentNullException(nameof(root));
}
if (verboseOption is null)
{
throw new ArgumentNullException(nameof(verboseOption));
}
var modules = LoadModules();
if (modules.Count == 0)
{
return;
}
foreach (var module in modules)
{
if (!module.IsAvailable(_services))
{
_logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name);
continue;
}
try
{
module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken);
_logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name);
}
}
}
private static string ResolveAssemblyPath(CliPluginManifest manifest)
{
if (manifest.EntryPoint is null)
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point.");
}
var assemblyPath = manifest.EntryPoint.Assembly;
if (string.IsNullOrWhiteSpace(assemblyPath))
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path.");
}
if (!Path.IsPathRooted(assemblyPath))
{
if (string.IsNullOrWhiteSpace(manifest.SourceDirectory))
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata.");
}
assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath);
}
return Normalize(assemblyPath);
}
private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest)
{
if (manifest.EntryPoint is null)
{
return null;
}
var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true);
if (type is null)
{
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'.");
}
var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule;
if (module is null)
{
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}.");
}
return module;
}
private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options)
{
var baseDirectory = options.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = AppContext.BaseDirectory;
}
return Path.GetFullPath(baseDirectory);
}
private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory)
{
var directory = options.Directory;
if (string.IsNullOrWhiteSpace(directory))
{
directory = Path.Combine("plugins", "cli");
}
directory = directory.Trim();
if (!Path.IsPathRooted(directory))
{
directory = Path.Combine(baseDirectory, directory);
}
return Path.GetFullPath(directory);
}
private static IReadOnlyList<string> ResolveSearchPatterns(StellaOpsCliPluginOptions options)
{
if (options.SearchPatterns is null || options.SearchPatterns.Count == 0)
{
return new[] { "StellaOps.Cli.Plugin.*.dll" };
}
return options.SearchPatterns
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
.Select(pattern => pattern.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string Normalize(string path)
{
var full = Path.GetFullPath(path);
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Plugins;
public sealed record CliPluginManifest
{
public const string CurrentSchemaVersion = "1.0";
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
public string Id { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Version { get; init; } = "0.0.0";
public bool RequiresRestart { get; init; } = true;
public CliPluginEntryPoint? EntryPoint { get; init; }
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, string> Metadata { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public string? SourcePath { get; init; }
public string? SourceDirectory { get; init; }
}
public sealed record CliPluginEntryPoint
{
public string Type { get; init; } = "dotnet";
public string Assembly { get; init; } = string.Empty;
public string TypeName { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Plugins;
internal sealed class CliPluginManifestLoader
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true
};
private readonly string _directory;
private readonly string _searchPattern;
public CliPluginManifestLoader(string directory, string searchPattern)
{
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("Plug-in manifest directory is required.", nameof(directory));
}
if (string.IsNullOrWhiteSpace(searchPattern))
{
throw new ArgumentException("Manifest search pattern is required.", nameof(searchPattern));
}
_directory = Path.GetFullPath(directory);
_searchPattern = searchPattern;
}
public async Task<IReadOnlyList<CliPluginManifest>> LoadAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(_directory))
{
return Array.Empty<CliPluginManifest>();
}
var manifests = new List<CliPluginManifest>();
foreach (var file in Directory.EnumerateFiles(_directory, _searchPattern, SearchOption.AllDirectories))
{
if (IsHidden(file))
{
continue;
}
var manifest = await DeserializeAsync(file, cancellationToken).ConfigureAwait(false);
manifests.Add(manifest);
}
return manifests
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static bool IsHidden(string path)
{
var directory = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(directory))
{
var name = Path.GetFileName(directory);
if (name.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static async Task<CliPluginManifest> DeserializeAsync(string file, CancellationToken cancellationToken)
{
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
CliPluginManifest? manifest;
try
{
manifest = await JsonSerializer.DeserializeAsync<CliPluginManifest>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to parse CLI plug-in manifest '{file}'.", ex);
}
if (manifest is null)
{
throw new InvalidOperationException($"CLI plug-in manifest '{file}' is empty or invalid.");
}
ValidateManifest(manifest, file);
var directory = Path.GetDirectoryName(file);
return manifest with
{
SourcePath = file,
SourceDirectory = directory
};
}
private static void ValidateManifest(CliPluginManifest manifest, string file)
{
if (!string.Equals(manifest.SchemaVersion, CliPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{CliPluginManifest.CurrentSchemaVersion}'.");
}
if (string.IsNullOrWhiteSpace(manifest.Id))
{
throw new InvalidOperationException($"Manifest '{file}' must specify a non-empty 'id'.");
}
if (manifest.EntryPoint is null)
{
throw new InvalidOperationException($"Manifest '{file}' must specify an 'entryPoint'.");
}
if (!string.Equals(manifest.EntryPoint.Type, "dotnet", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Manifest '{file}' entry point type '{manifest.EntryPoint.Type}' is not supported. Expected 'dotnet'.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Assembly))
{
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point assembly.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.TypeName))
{
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point type.");
}
if (!manifest.RequiresRestart)
{
throw new InvalidOperationException($"Manifest '{file}' must set 'requiresRestart' to true.");
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.CommandLine;
using System.Threading;
using StellaOps.Cli.Configuration;
namespace StellaOps.Cli.Plugins;
public interface ICliCommandModule
{
string Name { get; }
bool IsAvailable(IServiceProvider services);
void RegisterCommands(
RootCommand root,
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
namespace StellaOps.Cli.Plugins;
internal sealed class RestartOnlyCliPluginGuard
{
private readonly ConcurrentDictionary<string, byte> _plugins = new(StringComparer.OrdinalIgnoreCase);
private bool _sealed;
public IReadOnlyCollection<string> KnownPlugins => _plugins.Keys.ToArray();
public bool IsSealed => Volatile.Read(ref _sealed);
public void EnsureRegistrationAllowed(string pluginPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath);
var normalized = Normalize(pluginPath);
if (IsSealed && !_plugins.ContainsKey(normalized))
{
throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required.");
}
_plugins.TryAdd(normalized, 0);
}
public void Seal()
{
Volatile.Write(ref _sealed, true);
}
private static string Normalize(string path)
{
var full = Path.GetFullPath(path);
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}

View File

@@ -116,7 +116,7 @@ internal static class Program
cts.Cancel();
};
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token);
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token, loggerFactory);
var commandConfiguration = new CommandLineConfiguration(rootCommand);
var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false);

View File

@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]
[assembly: InternalsVisibleTo("StellaOps.Cli.Plugins.NonCore")]

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
@@ -39,6 +40,7 @@
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -19,6 +19,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|EXCITITOR-CLI-01-003 CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|**DOING (2025-10-19)** Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.|
|CLI-RUNTIME-13-005 Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|**DONE (2025-10-19)** Added `runtime policy test` command (stdin/file support, JSON output), backend client method + typed models, verdict table output, docs/tests updated (`dotnet test src/StellaOps.Cli.Tests`).|
|CLI-OFFLINE-13-006 Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|**DONE (2025-10-21)** Added `offline kit pull/import/status` commands with resumable downloads, digest/metadata validation, metrics, docs updates, and regression coverage (`dotnet test src/StellaOps.Cli.Tests`).|
|CLI-PLUGIN-13-007 Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).|
|CLI-PLUGIN-13-007 Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|DONE (2025-10-22) Packaged non-core verbs as restart-time plug-ins with manifest + loader updates and tests ensuring no hot reload.|
|CLI-RUNTIME-13-008 Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|**DONE (2025-10-19)** CLI runtime table/JSON now align with SCANNER-RUNTIME-12-302 (SBOM referrers, quieted provenance, confidence, verified Rekor); docs/09 updated with joint sign-off note.|
|CLI-RUNTIME-13-009 Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|**DONE (2025-10-19)** Spectre console harness + regression tests cover table and `--json` output paths for `runtime policy test`, using stubbed backend and integrated into `dotnet test` suite.|

View File

@@ -0,0 +1,127 @@
{
"advisoryKey": "GHSA-aaaa-bbbb-cccc",
"affectedPackages": [
{
"type": "semver",
"identifier": "pkg:npm/example-widget",
"platform": null,
"versionRanges": [
{
"fixedVersion": "2.5.1",
"introducedVersion": null,
"lastAffectedVersion": null,
"primitives": null,
"provenance": {
"source": "ghsa",
"kind": "map",
"value": "ghsa-aaaa-bbbb-cccc",
"decisionReason": null,
"recordedAt": "2024-03-05T10:00:00+00:00",
"fieldMask": []
},
"rangeExpression": ">=0.0.0 <2.5.1",
"rangeKind": "semver"
},
{
"fixedVersion": "3.2.4",
"introducedVersion": "3.0.0",
"lastAffectedVersion": null,
"primitives": null,
"provenance": {
"source": "ghsa",
"kind": "map",
"value": "ghsa-aaaa-bbbb-cccc",
"decisionReason": null,
"recordedAt": "2024-03-05T10:00:00+00:00",
"fieldMask": []
},
"rangeExpression": null,
"rangeKind": "semver"
}
],
"normalizedVersions": [],
"statuses": [],
"provenance": [
{
"source": "ghsa",
"kind": "map",
"value": "ghsa-aaaa-bbbb-cccc",
"decisionReason": null,
"recordedAt": "2024-03-05T10:00:00+00:00",
"fieldMask": []
}
]
}
],
"aliases": [
"CVE-2024-2222",
"GHSA-aaaa-bbbb-cccc"
],
"canonicalMetricId": null,
"credits": [],
"cvssMetrics": [
{
"baseScore": 8.8,
"baseSeverity": "high",
"provenance": {
"source": "ghsa",
"kind": "map",
"value": "ghsa-aaaa-bbbb-cccc",
"decisionReason": null,
"recordedAt": "2024-03-05T10:00:00+00:00",
"fieldMask": []
},
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
"version": "3.1"
}
],
"cwes": [],
"description": null,
"exploitKnown": false,
"language": "en",
"modified": "2024-03-04T12:00:00+00:00",
"provenance": [
{
"source": "ghsa",
"kind": "map",
"value": "ghsa-aaaa-bbbb-cccc",
"decisionReason": null,
"recordedAt": "2024-03-05T10:00:00+00:00",
"fieldMask": []
}
],
"published": "2024-03-04T00:00:00+00:00",
"references": [
{
"kind": "patch",
"provenance": {
"source": "ghsa",
"kind": "map",
"value": "ghsa-aaaa-bbbb-cccc",
"decisionReason": null,
"recordedAt": "2024-03-05T10:00:00+00:00",
"fieldMask": []
},
"sourceTag": "ghsa",
"summary": "Patch commit",
"url": "https://github.com/example/widget/commit/abcd1234"
},
{
"kind": "advisory",
"provenance": {
"source": "ghsa",
"kind": "map",
"value": "ghsa-aaaa-bbbb-cccc",
"decisionReason": null,
"recordedAt": "2024-03-05T10:00:00+00:00",
"fieldMask": []
},
"sourceTag": "ghsa",
"summary": "GitHub Security Advisory",
"url": "https://github.com/example/widget/security/advisories/GHSA-aaaa-bbbb-cccc"
}
],
"severity": "high",
"summary": "A crafted payload can pollute Object.prototype leading to RCE.",
"title": "Prototype pollution in widget.js"
}

View File

@@ -0,0 +1,45 @@
{
"advisoryKey": "CVE-2023-9999",
"affectedPackages": [],
"aliases": [
"CVE-2023-9999"
],
"canonicalMetricId": null,
"credits": [],
"cvssMetrics": [],
"cwes": [],
"description": null,
"exploitKnown": true,
"language": "en",
"modified": "2024-02-09T16:22:00+00:00",
"provenance": [
{
"source": "cisa-kev",
"kind": "annotate",
"value": "kev",
"decisionReason": null,
"recordedAt": "2024-02-10T09:30:00+00:00",
"fieldMask": []
}
],
"published": "2023-11-20T00:00:00+00:00",
"references": [
{
"kind": "kev",
"provenance": {
"source": "cisa-kev",
"kind": "annotate",
"value": "kev",
"decisionReason": null,
"recordedAt": "2024-02-10T09:30:00+00:00",
"fieldMask": []
},
"sourceTag": "cisa",
"summary": "CISA KEV entry",
"url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
}
],
"severity": "critical",
"summary": "Unauthenticated RCE due to unsafe deserialization.",
"title": "Remote code execution in LegacyServer"
}

View File

@@ -0,0 +1,122 @@
{
"advisoryKey": "CVE-2024-1234",
"affectedPackages": [
{
"type": "cpe",
"identifier": "cpe:/a:examplecms:examplecms:1.0",
"platform": null,
"versionRanges": [
{
"fixedVersion": "1.0.5",
"introducedVersion": "1.0",
"lastAffectedVersion": null,
"primitives": null,
"provenance": {
"source": "nvd",
"kind": "map",
"value": "cve-2024-1234",
"decisionReason": null,
"recordedAt": "2024-08-01T12:00:00+00:00",
"fieldMask": []
},
"rangeExpression": null,
"rangeKind": "version"
}
],
"normalizedVersions": [],
"statuses": [
{
"provenance": {
"source": "nvd",
"kind": "map",
"value": "cve-2024-1234",
"decisionReason": null,
"recordedAt": "2024-08-01T12:00:00+00:00",
"fieldMask": []
},
"status": "affected"
}
],
"provenance": [
{
"source": "nvd",
"kind": "map",
"value": "cve-2024-1234",
"decisionReason": null,
"recordedAt": "2024-08-01T12:00:00+00:00",
"fieldMask": []
}
]
}
],
"aliases": [
"CVE-2024-1234"
],
"canonicalMetricId": null,
"credits": [],
"cvssMetrics": [
{
"baseScore": 9.8,
"baseSeverity": "critical",
"provenance": {
"source": "nvd",
"kind": "map",
"value": "cve-2024-1234",
"decisionReason": null,
"recordedAt": "2024-08-01T12:00:00+00:00",
"fieldMask": []
},
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
}
],
"cwes": [],
"description": null,
"exploitKnown": false,
"language": "en",
"modified": "2024-07-16T10:35:00+00:00",
"provenance": [
{
"source": "nvd",
"kind": "map",
"value": "cve-2024-1234",
"decisionReason": null,
"recordedAt": "2024-08-01T12:00:00+00:00",
"fieldMask": []
}
],
"published": "2024-07-15T00:00:00+00:00",
"references": [
{
"kind": "advisory",
"provenance": {
"source": "example",
"kind": "fetch",
"value": "bulletin",
"decisionReason": null,
"recordedAt": "2024-07-14T15:00:00+00:00",
"fieldMask": []
},
"sourceTag": "vendor",
"summary": "Vendor bulletin",
"url": "https://example.org/security/CVE-2024-1234"
},
{
"kind": "advisory",
"provenance": {
"source": "nvd",
"kind": "map",
"value": "cve-2024-1234",
"decisionReason": null,
"recordedAt": "2024-08-01T12:00:00+00:00",
"fieldMask": []
},
"sourceTag": "nvd",
"summary": "NVD entry",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-1234"
}
],
"severity": "high",
"summary": "An integer overflow in ExampleCMS allows remote attackers to escalate privileges.",
"title": "Integer overflow in ExampleCMS"
}

View File

@@ -0,0 +1,125 @@
{
"advisoryKey": "RHSA-2024:0252",
"affectedPackages": [
{
"type": "rpm",
"identifier": "kernel-0:4.18.0-553.el8.x86_64",
"platform": "rhel-8",
"versionRanges": [
{
"fixedVersion": null,
"introducedVersion": "0:4.18.0-553.el8",
"lastAffectedVersion": null,
"primitives": null,
"provenance": {
"source": "redhat",
"kind": "map",
"value": "rhsa-2024:0252",
"decisionReason": null,
"recordedAt": "2024-05-11T09:00:00+00:00",
"fieldMask": []
},
"rangeExpression": null,
"rangeKind": "nevra"
}
],
"normalizedVersions": [],
"statuses": [
{
"provenance": {
"source": "redhat",
"kind": "map",
"value": "rhsa-2024:0252",
"decisionReason": null,
"recordedAt": "2024-05-11T09:00:00+00:00",
"fieldMask": []
},
"status": "fixed"
}
],
"provenance": [
{
"source": "redhat",
"kind": "enrich",
"value": "cve-2024-5678",
"decisionReason": null,
"recordedAt": "2024-05-11T09:05:00+00:00",
"fieldMask": []
},
{
"source": "redhat",
"kind": "map",
"value": "rhsa-2024:0252",
"decisionReason": null,
"recordedAt": "2024-05-11T09:00:00+00:00",
"fieldMask": []
}
]
}
],
"aliases": [
"CVE-2024-5678",
"RHSA-2024:0252"
],
"canonicalMetricId": null,
"credits": [],
"cvssMetrics": [
{
"baseScore": 6.7,
"baseSeverity": "medium",
"provenance": {
"source": "redhat",
"kind": "map",
"value": "rhsa-2024:0252",
"decisionReason": null,
"recordedAt": "2024-05-11T09:00:00+00:00",
"fieldMask": []
},
"vector": "CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
}
],
"cwes": [],
"description": null,
"exploitKnown": false,
"language": "en",
"modified": "2024-05-11T08:15:00+00:00",
"provenance": [
{
"source": "redhat",
"kind": "enrich",
"value": "cve-2024-5678",
"decisionReason": null,
"recordedAt": "2024-05-11T09:05:00+00:00",
"fieldMask": []
},
{
"source": "redhat",
"kind": "map",
"value": "rhsa-2024:0252",
"decisionReason": null,
"recordedAt": "2024-05-11T09:00:00+00:00",
"fieldMask": []
}
],
"published": "2024-05-10T19:28:00+00:00",
"references": [
{
"kind": "advisory",
"provenance": {
"source": "redhat",
"kind": "map",
"value": "rhsa-2024:0252",
"decisionReason": null,
"recordedAt": "2024-05-11T09:00:00+00:00",
"fieldMask": []
},
"sourceTag": "redhat",
"summary": "Red Hat security advisory",
"url": "https://access.redhat.com/errata/RHSA-2024:0252"
}
],
"severity": "critical",
"summary": "Updates the Red Hat Enterprise Linux kernel to address CVE-2024-5678.",
"title": "Important: kernel security update"
}

View File

@@ -0,0 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.DotNet";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new DotNetLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "dotnet";
public string DisplayName => ".NET Analyzer (preview)";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
if (packages.Count == 0)
{
return;
}
foreach (var package in packages)
{
cancellationToken.ThrowIfCancellationRequested();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "nuget",
metadata: package.Metadata,
evidence: package.Evidence,
usedByEntrypoint: package.UsedByEntrypoint);
}
}
}

View File

@@ -0,0 +1,416 @@
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal static class DotNetDependencyCollector
{
private static readonly EnumerationOptions Enumeration = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
};
public static ValueTask<IReadOnlyList<DotNetPackage>> CollectAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var depsFiles = Directory
.EnumerateFiles(context.RootPath, "*.deps.json", Enumeration)
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
if (depsFiles.Length == 0)
{
return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(Array.Empty<DotNetPackage>());
}
var aggregator = new DotNetPackageAggregator();
foreach (var depsPath in depsFiles)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var relativeDepsPath = NormalizeRelative(context.GetRelativePath(depsPath));
var depsFile = DotNetDepsFile.Load(depsPath, relativeDepsPath, cancellationToken);
if (depsFile is null)
{
continue;
}
DotNetRuntimeConfig? runtimeConfig = null;
var runtimeConfigPath = Path.ChangeExtension(depsPath, ".runtimeconfig.json");
if (!string.IsNullOrEmpty(runtimeConfigPath) && File.Exists(runtimeConfigPath))
{
var relativeRuntimePath = NormalizeRelative(context.GetRelativePath(runtimeConfigPath));
runtimeConfig = DotNetRuntimeConfig.Load(runtimeConfigPath, relativeRuntimePath, cancellationToken);
}
aggregator.Add(depsFile, runtimeConfig);
}
catch (IOException)
{
continue;
}
catch (JsonException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
}
var packages = aggregator.Build();
return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(packages);
}
private static string NormalizeRelative(string path)
{
if (string.IsNullOrWhiteSpace(path) || path == ".")
{
return ".";
}
var normalized = path.Replace('\\', '/');
return string.IsNullOrWhiteSpace(normalized) ? "." : normalized;
}
}
internal sealed class DotNetPackageAggregator
{
private readonly Dictionary<string, DotNetPackageBuilder> _packages = new(StringComparer.Ordinal);
public void Add(DotNetDepsFile depsFile, DotNetRuntimeConfig? runtimeConfig)
{
ArgumentNullException.ThrowIfNull(depsFile);
foreach (var library in depsFile.Libraries.Values)
{
if (!library.IsPackage)
{
continue;
}
var normalizedId = DotNetPackageBuilder.NormalizeId(library.Id);
var key = DotNetPackageBuilder.BuildKey(normalizedId, library.Version);
if (!_packages.TryGetValue(key, out var builder))
{
builder = new DotNetPackageBuilder(library.Id, normalizedId, library.Version);
_packages[key] = builder;
}
builder.AddLibrary(library, depsFile.RelativePath, runtimeConfig);
}
}
public IReadOnlyList<DotNetPackage> Build()
{
if (_packages.Count == 0)
{
return Array.Empty<DotNetPackage>();
}
var items = new List<DotNetPackage>(_packages.Count);
foreach (var builder in _packages.Values)
{
items.Add(builder.Build());
}
items.Sort(static (left, right) => string.CompareOrdinal(left.ComponentKey, right.ComponentKey));
return items;
}
}
internal sealed class DotNetPackageBuilder
{
private readonly string _originalId;
private readonly string _normalizedId;
private readonly string _version;
private bool? _serviceable;
private readonly SortedSet<string> _sha512 = new(StringComparer.Ordinal);
private readonly SortedSet<string> _packagePaths = new(StringComparer.Ordinal);
private readonly SortedSet<string> _hashPaths = new(StringComparer.Ordinal);
private readonly SortedSet<string> _depsPaths = new(StringComparer.Ordinal);
private readonly SortedSet<string> _targetFrameworks = new(StringComparer.Ordinal);
private readonly SortedSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal);
private readonly SortedSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
private readonly SortedSet<string> _runtimeConfigPaths = new(StringComparer.Ordinal);
private readonly SortedSet<string> _runtimeConfigTfms = new(StringComparer.OrdinalIgnoreCase);
private readonly SortedSet<string> _runtimeConfigFrameworks = new(StringComparer.OrdinalIgnoreCase);
private readonly SortedSet<string> _runtimeConfigGraph = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer());
public DotNetPackageBuilder(string originalId, string normalizedId, string version)
{
_originalId = string.IsNullOrWhiteSpace(originalId) ? normalizedId : originalId.Trim();
_normalizedId = normalizedId;
_version = version ?? string.Empty;
}
public static string BuildKey(string normalizedId, string version)
=> $"{normalizedId}::{version}";
public static string NormalizeId(string id)
=> string.IsNullOrWhiteSpace(id) ? string.Empty : id.Trim().ToLowerInvariant();
public void AddLibrary(DotNetLibrary library, string relativeDepsPath, DotNetRuntimeConfig? runtimeConfig)
{
ArgumentNullException.ThrowIfNull(library);
if (library.Serviceable is bool serviceable)
{
_serviceable = _serviceable.HasValue
? _serviceable.Value || serviceable
: serviceable;
}
AddIfPresent(_sha512, library.Sha512);
AddIfPresent(_packagePaths, library.PackagePath);
AddIfPresent(_hashPaths, library.HashPath);
AddIfPresent(_depsPaths, NormalizeRelativePath(relativeDepsPath));
foreach (var dependency in library.Dependencies)
{
AddIfPresent(_dependencies, dependency, normalizeLower: true);
}
foreach (var tfm in library.TargetFrameworks)
{
AddIfPresent(_targetFrameworks, tfm);
}
foreach (var rid in library.RuntimeIdentifiers)
{
AddIfPresent(_runtimeIdentifiers, rid);
}
_evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"deps.json",
NormalizeRelativePath(relativeDepsPath),
library.Key,
Sha256: null));
if (runtimeConfig is not null)
{
AddIfPresent(_runtimeConfigPaths, runtimeConfig.RelativePath);
foreach (var tfm in runtimeConfig.Tfms)
{
AddIfPresent(_runtimeConfigTfms, tfm);
}
foreach (var framework in runtimeConfig.Frameworks)
{
AddIfPresent(_runtimeConfigFrameworks, framework);
}
foreach (var entry in runtimeConfig.RuntimeGraph)
{
var value = BuildRuntimeGraphValue(entry.Rid, entry.Fallbacks);
AddIfPresent(_runtimeConfigGraph, value);
}
_evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"runtimeconfig.json",
runtimeConfig.RelativePath,
Value: null,
Sha256: null));
}
}
public DotNetPackage Build()
{
var metadata = new List<KeyValuePair<string, string?>>(32)
{
new("package.id", _originalId),
new("package.id.normalized", _normalizedId),
new("package.version", _version)
};
if (_serviceable.HasValue)
{
metadata.Add(new KeyValuePair<string, string?>("package.serviceable", _serviceable.Value ? "true" : "false"));
}
AddIndexed(metadata, "package.sha512", _sha512);
AddIndexed(metadata, "package.path", _packagePaths);
AddIndexed(metadata, "package.hashPath", _hashPaths);
AddIndexed(metadata, "deps.path", _depsPaths);
AddIndexed(metadata, "deps.dependency", _dependencies);
AddIndexed(metadata, "deps.tfm", _targetFrameworks);
AddIndexed(metadata, "deps.rid", _runtimeIdentifiers);
AddIndexed(metadata, "runtimeconfig.path", _runtimeConfigPaths);
AddIndexed(metadata, "runtimeconfig.tfm", _runtimeConfigTfms);
AddIndexed(metadata, "runtimeconfig.framework", _runtimeConfigFrameworks);
AddIndexed(metadata, "runtimeconfig.graph", _runtimeConfigGraph);
metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
var evidence = _evidence
.OrderBy(static item => item.Source, StringComparer.Ordinal)
.ThenBy(static item => item.Locator, StringComparer.Ordinal)
.ThenBy(static item => item.Value, StringComparer.Ordinal)
.ToArray();
return new DotNetPackage(
name: _originalId,
normalizedId: _normalizedId,
version: _version,
metadata: metadata,
evidence: evidence,
usedByEntrypoint: false);
}
private static void AddIfPresent(ISet<string> set, string? value, bool normalizeLower = false)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
var normalized = value.Trim();
if (normalizeLower)
{
normalized = normalized.ToLowerInvariant();
}
set.Add(normalized);
}
private static void AddIndexed(ICollection<KeyValuePair<string, string?>> metadata, string prefix, IEnumerable<string> values)
{
if (metadata is null)
{
throw new ArgumentNullException(nameof(metadata));
}
if (values is null)
{
return;
}
var index = 0;
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index++}]", value));
}
}
private static string NormalizeRelativePath(string path)
{
if (string.IsNullOrWhiteSpace(path) || path == ".")
{
return ".";
}
return path.Replace('\\', '/');
}
private static string BuildRuntimeGraphValue(string rid, IReadOnlyList<string> fallbacks)
{
if (string.IsNullOrWhiteSpace(rid))
{
return string.Empty;
}
if (fallbacks.Count == 0)
{
return rid.Trim();
}
var ordered = fallbacks
.Where(static fallback => !string.IsNullOrWhiteSpace(fallback))
.Select(static fallback => fallback.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static fallback => fallback, StringComparer.OrdinalIgnoreCase)
.ToArray();
return ordered.Length == 0
? rid.Trim()
: $"{rid.Trim()}=>{string.Join(';', ordered)}";
}
private sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence>
{
public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return x.Kind == y.Kind &&
string.Equals(x.Source, y.Source, StringComparison.Ordinal) &&
string.Equals(x.Locator, y.Locator, StringComparison.Ordinal) &&
string.Equals(x.Value, y.Value, StringComparison.Ordinal) &&
string.Equals(x.Sha256, y.Sha256, StringComparison.Ordinal);
}
public int GetHashCode(LanguageComponentEvidence obj)
{
var hash = new HashCode();
hash.Add(obj.Kind);
hash.Add(obj.Source, StringComparer.Ordinal);
hash.Add(obj.Locator, StringComparer.Ordinal);
hash.Add(obj.Value, StringComparer.Ordinal);
hash.Add(obj.Sha256, StringComparer.Ordinal);
return hash.ToHashCode();
}
}
}
internal sealed class DotNetPackage
{
public DotNetPackage(
string name,
string normalizedId,
string version,
IReadOnlyList<KeyValuePair<string, string?>> metadata,
IReadOnlyCollection<LanguageComponentEvidence> evidence,
bool usedByEntrypoint)
{
Name = string.IsNullOrWhiteSpace(name) ? normalizedId : name.Trim();
NormalizedId = normalizedId;
Version = version ?? string.Empty;
Metadata = metadata ?? Array.Empty<KeyValuePair<string, string?>>();
Evidence = evidence ?? Array.Empty<LanguageComponentEvidence>();
UsedByEntrypoint = usedByEntrypoint;
}
public string Name { get; }
public string NormalizedId { get; }
public string Version { get; }
public IReadOnlyList<KeyValuePair<string, string?>> Metadata { get; }
public IReadOnlyCollection<LanguageComponentEvidence> Evidence { get; }
public bool UsedByEntrypoint { get; }
public string Purl => $"pkg:nuget/{NormalizedId}@{Version}";
public string ComponentKey => $"purl::{Purl}";
}

View File

@@ -0,0 +1,318 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetDepsFile
{
private DotNetDepsFile(string relativePath, IReadOnlyDictionary<string, DotNetLibrary> libraries)
{
RelativePath = relativePath;
Libraries = libraries;
}
public string RelativePath { get; }
public IReadOnlyDictionary<string, DotNetLibrary> Libraries { get; }
public static DotNetDepsFile? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (root.ValueKind is not JsonValueKind.Object)
{
return null;
}
var libraries = ParseLibraries(root, cancellationToken);
if (libraries.Count == 0)
{
return null;
}
PopulateTargets(root, libraries, cancellationToken);
return new DotNetDepsFile(relativePath, libraries);
}
private static Dictionary<string, DotNetLibrary> ParseLibraries(JsonElement root, CancellationToken cancellationToken)
{
var result = new Dictionary<string, DotNetLibrary>(StringComparer.Ordinal);
if (!root.TryGetProperty("libraries", out var librariesElement) || librariesElement.ValueKind is not JsonValueKind.Object)
{
return result;
}
foreach (var property in librariesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (DotNetLibrary.TryCreate(property.Name, property.Value, out var library))
{
result[property.Name] = library;
}
}
return result;
}
private static void PopulateTargets(JsonElement root, IDictionary<string, DotNetLibrary> libraries, CancellationToken cancellationToken)
{
if (!root.TryGetProperty("targets", out var targetsElement) || targetsElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var targetProperty in targetsElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
var (tfm, rid) = ParseTargetKey(targetProperty.Name);
if (targetProperty.Value.ValueKind is not JsonValueKind.Object)
{
continue;
}
foreach (var libraryProperty in targetProperty.Value.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (!libraries.TryGetValue(libraryProperty.Name, out var library))
{
continue;
}
if (!string.IsNullOrEmpty(tfm))
{
library.AddTargetFramework(tfm);
}
if (!string.IsNullOrEmpty(rid))
{
library.AddRuntimeIdentifier(rid);
}
library.MergeTargetMetadata(libraryProperty.Value);
}
}
}
private static (string tfm, string? rid) ParseTargetKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return (string.Empty, null);
}
var separatorIndex = value.IndexOf('/');
if (separatorIndex < 0)
{
return (value.Trim(), null);
}
var tfm = value[..separatorIndex].Trim();
var rid = value[(separatorIndex + 1)..].Trim();
return (tfm, string.IsNullOrEmpty(rid) ? null : rid);
}
}
internal sealed class DotNetLibrary
{
private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal);
private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal);
private DotNetLibrary(
string key,
string id,
string version,
string type,
bool? serviceable,
string? sha512,
string? path,
string? hashPath)
{
Key = key;
Id = id;
Version = version;
Type = type;
Serviceable = serviceable;
Sha512 = NormalizeValue(sha512);
PackagePath = NormalizePath(path);
HashPath = NormalizePath(hashPath);
}
public string Key { get; }
public string Id { get; }
public string Version { get; }
public string Type { get; }
public bool? Serviceable { get; }
public string? Sha512 { get; }
public string? PackagePath { get; }
public string? HashPath { get; }
public bool IsPackage => string.Equals(Type, "package", StringComparison.OrdinalIgnoreCase);
public IReadOnlyCollection<string> Dependencies => _dependencies;
public IReadOnlyCollection<string> TargetFrameworks => _targetFrameworks;
public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers;
public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library)
{
library = null;
if (!TrySplitNameAndVersion(key, out var id, out var version))
{
return false;
}
var type = element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String
? typeElement.GetString() ?? string.Empty
: string.Empty;
bool? serviceable = null;
if (element.TryGetProperty("serviceable", out var serviceableElement))
{
if (serviceableElement.ValueKind is JsonValueKind.True)
{
serviceable = true;
}
else if (serviceableElement.ValueKind is JsonValueKind.False)
{
serviceable = false;
}
}
var sha512 = element.TryGetProperty("sha512", out var sha512Element) && sha512Element.ValueKind == JsonValueKind.String
? sha512Element.GetString()
: null;
var path = element.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String
? pathElement.GetString()
: null;
var hashPath = element.TryGetProperty("hashPath", out var hashElement) && hashElement.ValueKind == JsonValueKind.String
? hashElement.GetString()
: null;
library = new DotNetLibrary(key, id, version, type, serviceable, sha512, path, hashPath);
library.MergeLibraryMetadata(element);
return true;
}
public void AddTargetFramework(string tfm)
{
if (!string.IsNullOrWhiteSpace(tfm))
{
_targetFrameworks.Add(tfm);
}
}
public void AddRuntimeIdentifier(string rid)
{
if (!string.IsNullOrWhiteSpace(rid))
{
_runtimeIdentifiers.Add(rid);
}
}
public void MergeTargetMetadata(JsonElement element)
{
if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var dependencyProperty in dependenciesElement.EnumerateObject())
{
AddDependency(dependencyProperty.Name);
}
}
public void MergeLibraryMetadata(JsonElement element)
{
if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var dependencyProperty in dependenciesElement.EnumerateObject())
{
AddDependency(dependencyProperty.Name);
}
}
private void AddDependency(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
var dependencyId = name;
if (TrySplitNameAndVersion(name, out var parsedName, out _))
{
dependencyId = parsedName;
}
if (!string.IsNullOrWhiteSpace(dependencyId))
{
_dependencies.Add(dependencyId);
}
}
private static bool TrySplitNameAndVersion(string key, out string name, out string version)
{
name = string.Empty;
version = string.Empty;
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
var separatorIndex = key.LastIndexOf('/');
if (separatorIndex <= 0 || separatorIndex >= key.Length - 1)
{
return false;
}
name = key[..separatorIndex].Trim();
version = key[(separatorIndex + 1)..].Trim();
return name.Length > 0 && version.Length > 0;
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return path.Replace('\\', '/');
}
private static string? NormalizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}

View File

@@ -0,0 +1,158 @@
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetRuntimeConfig
{
private DotNetRuntimeConfig(
string relativePath,
IReadOnlyCollection<string> tfms,
IReadOnlyCollection<string> frameworks,
IReadOnlyCollection<RuntimeGraphEntry> runtimeGraph)
{
RelativePath = relativePath;
Tfms = tfms;
Frameworks = frameworks;
RuntimeGraph = runtimeGraph;
}
public string RelativePath { get; }
public IReadOnlyCollection<string> Tfms { get; }
public IReadOnlyCollection<string> Frameworks { get; }
public IReadOnlyCollection<RuntimeGraphEntry> RuntimeGraph { get; }
public static DotNetRuntimeConfig? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (!root.TryGetProperty("runtimeOptions", out var runtimeOptions) || runtimeOptions.ValueKind is not JsonValueKind.Object)
{
return null;
}
var tfms = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var frameworks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var runtimeGraph = new List<RuntimeGraphEntry>();
if (runtimeOptions.TryGetProperty("tfm", out var tfmElement) && tfmElement.ValueKind == JsonValueKind.String)
{
AddIfPresent(tfms, tfmElement.GetString());
}
if (runtimeOptions.TryGetProperty("framework", out var frameworkElement) && frameworkElement.ValueKind == JsonValueKind.Object)
{
var frameworkId = FormatFramework(frameworkElement);
AddIfPresent(frameworks, frameworkId);
}
if (runtimeOptions.TryGetProperty("frameworks", out var frameworksElement) && frameworksElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in frameworksElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("includedFrameworks", out var includedElement) && includedElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in includedElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("runtimeGraph", out var runtimeGraphElement) &&
runtimeGraphElement.ValueKind == JsonValueKind.Object &&
runtimeGraphElement.TryGetProperty("runtimes", out var runtimesElement) &&
runtimesElement.ValueKind == JsonValueKind.Object)
{
foreach (var ridProperty in runtimesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(ridProperty.Name))
{
continue;
}
var fallbacks = new List<string>();
if (ridProperty.Value.ValueKind == JsonValueKind.Object &&
ridProperty.Value.TryGetProperty("fallbacks", out var fallbacksElement) &&
fallbacksElement.ValueKind == JsonValueKind.Array)
{
foreach (var fallback in fallbacksElement.EnumerateArray())
{
if (fallback.ValueKind == JsonValueKind.String)
{
var fallbackValue = fallback.GetString();
if (!string.IsNullOrWhiteSpace(fallbackValue))
{
fallbacks.Add(fallbackValue.Trim());
}
}
}
}
runtimeGraph.Add(new RuntimeGraphEntry(ridProperty.Name.Trim(), fallbacks));
}
}
return new DotNetRuntimeConfig(
relativePath,
tfms.ToArray(),
frameworks.ToArray(),
runtimeGraph);
}
private static void AddIfPresent(ISet<string> set, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
set.Add(value.Trim());
}
}
private static string? FormatFramework(JsonElement element)
{
if (element.ValueKind is not JsonValueKind.Object)
{
return null;
}
var name = element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String
? nameElement.GetString()
: null;
var version = element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String
? versionElement.GetString()
: null;
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
if (string.IsNullOrWhiteSpace(version))
{
return name.Trim();
}
return $"{name.Trim()}@{version.Trim()}";
}
internal sealed record RuntimeGraphEntry(string Rid, IReadOnlyList<string> Fallbacks);
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
internal static class Placeholder
{
// Analyzer implementation will be added during Sprint LA4.
}

View File

@@ -2,7 +2,7 @@
| Seq | ID | Status | Depends on | Description | Exit Criteria |
|-----|----|--------|------------|-------------|---------------|
| 1 | SCANNER-ANALYZERS-LANG-10-305A | TODO | SCANNER-ANALYZERS-LANG-10-307 | Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components. | RID graph deterministic; fixtures confirm consistent component ordering; fallback to `bin:{sha256}` documented. |
| 1 | SCANNER-ANALYZERS-LANG-10-305A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components. | RID graph deterministic; fixtures confirm consistent component ordering; fallback to `bin:{sha256}` documented. |
| 2 | SCANNER-ANALYZERS-LANG-10-305B | TODO | SCANNER-ANALYZERS-LANG-10-305A | Extract assembly metadata (strong name, file/product info) and optional Authenticode details when offline cert bundle provided. | Signing metadata captured for signed assemblies; offline trust store documented; hash validations deterministic. |
| 3 | SCANNER-ANALYZERS-LANG-10-305C | TODO | SCANNER-ANALYZERS-LANG-10-305B | Handle self-contained apps and native assets; merge with EntryTrace usage hints. | Self-contained fixtures map to components with RID flags; usage hints propagate; tests cover linux/win variants. |
| 4 | SCANNER-ANALYZERS-LANG-10-307D | TODO | SCANNER-ANALYZERS-LANG-10-305C | Integrate shared helpers (license mapping, quiet provenance) and concurrency-safe caches. | Shared helpers reused; concurrency tests for parallel layer scans pass; no redundant allocations. |

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.dotnet",
"displayName": "StellaOps .NET Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.DotNet.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.DotNet.DotNetAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"dotnet",
"nuget"
],
"metadata": {
"org.stellaops.analyzer.language": "dotnet",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}

View File

@@ -0,0 +1,118 @@
[
{
"analyzerId": "golang",
"componentKey": "purl::pkg:golang/example.com/app@v1.2.3",
"purl": "pkg:golang/example.com/app@v1.2.3",
"name": "example.com/app",
"version": "v1.2.3",
"type": "golang",
"usedByEntrypoint": false,
"metadata": {
"binaryPath": "app",
"build.GOARCH": "amd64",
"build.GOOS": "linux",
"build.vcs": "git",
"build.vcs.modified": "false",
"build.vcs.revision": "1234567890abcdef1234567890abcdef12345678",
"build.vcs.time": "2025-09-14T12:34:56Z",
"go.version": "go1.22.5",
"modulePath": "example.com/app",
"modulePath.main": "example.com/app",
"moduleSum": "h1:mainchecksum",
"moduleVersion": "v1.2.3"
},
"evidence": [
{
"kind": "metadata",
"source": "go.buildinfo.setting",
"locator": "GOARCH",
"value": "amd64"
},
{
"kind": "metadata",
"source": "go.buildinfo.setting",
"locator": "GOOS",
"value": "linux"
},
{
"kind": "metadata",
"source": "go.buildinfo.setting",
"locator": "vcs.modified",
"value": "false"
},
{
"kind": "metadata",
"source": "go.buildinfo.setting",
"locator": "vcs.revision",
"value": "1234567890abcdef1234567890abcdef12345678"
},
{
"kind": "metadata",
"source": "go.buildinfo.setting",
"locator": "vcs.time",
"value": "2025-09-14T12:34:56Z"
},
{
"kind": "metadata",
"source": "go.buildinfo.setting",
"locator": "vcs",
"value": "git"
},
{
"kind": "metadata",
"source": "go.buildinfo",
"locator": "module:example.com/app",
"value": "v1.2.3",
"sha256": "h1:mainchecksum"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs.modified",
"value": "false"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs.revision",
"value": "1234567890abcdef1234567890abcdef12345678"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs.time",
"value": "2025-09-14T12:34:56Z"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs",
"value": "git"
}
]
},
{
"analyzerId": "golang",
"componentKey": "purl::pkg:golang/example.com/lib@v1.0.0",
"purl": "pkg:golang/example.com/lib@v1.0.0",
"name": "example.com/lib",
"version": "v1.0.0",
"type": "golang",
"usedByEntrypoint": false,
"metadata": {
"binaryPath": "app",
"modulePath": "example.com/lib",
"moduleSum": "h1:depchecksum",
"moduleVersion": "v1.0.0"
},
"evidence": [
{
"kind": "metadata",
"source": "go.buildinfo",
"locator": "module:example.com/lib",
"value": "v1.0.0",
"sha256": "h1:depchecksum"
}
]
}
]

View File

@@ -0,0 +1,80 @@
[
{
"analyzerId": "golang",
"componentKey": "purl::pkg:golang/example.com/app@v0.0.0",
"purl": "pkg:golang/example.com/app@v0.0.0",
"name": "example.com/app",
"version": "v0.0.0",
"type": "golang",
"usedByEntrypoint": false,
"metadata": {
"binaryPath": "app",
"build.vcs": "git",
"build.vcs.modified": "true",
"build.vcs.revision": "abcdef0123456789abcdef0123456789abcdef01",
"build.vcs.time": "2025-01-02T03:04:05Z",
"go.version": "go1.20.3",
"modulePath": "example.com/app",
"modulePath.main": "example.com/app",
"moduleSum": "h1:dwarfchecksum",
"moduleVersion": "v0.0.0"
},
"evidence": [
{
"kind": "metadata",
"source": "go.buildinfo",
"locator": "module:example.com/app",
"value": "v0.0.0",
"sha256": "h1:dwarfchecksum"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs.modified",
"value": "true"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs.revision",
"value": "abcdef0123456789abcdef0123456789abcdef01"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs.time",
"value": "2025-01-02T03:04:05Z"
},
{
"kind": "metadata",
"source": "go.dwarf",
"locator": "vcs",
"value": "git"
}
]
},
{
"analyzerId": "golang",
"componentKey": "purl::pkg:golang/example.com/lib@v0.1.0",
"purl": "pkg:golang/example.com/lib@v0.1.0",
"name": "example.com/lib",
"version": "v0.1.0",
"type": "golang",
"usedByEntrypoint": false,
"metadata": {
"binaryPath": "app",
"modulePath": "example.com/lib",
"moduleSum": "h1:libchecksum",
"moduleVersion": "v0.1.0"
},
"evidence": [
{
"kind": "metadata",
"source": "go.buildinfo",
"locator": "module:example.com/lib",
"value": "v0.1.0",
"sha256": "h1:libchecksum"
}
]
}
]

View File

@@ -0,0 +1,47 @@
using System.IO;
using StellaOps.Scanner.Analyzers.Lang.Go;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests;
public sealed class GoLanguageAnalyzerTests
{
[Fact]
public async Task BuildInfoFixtureProducesDeterministicOutputAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "go", "basic");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new GoLanguageAnalyzer(),
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
[Fact]
public async Task DwarfOnlyFixtureFallsBackToMetadataAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "go", "dwarf-only");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new GoLanguageAnalyzer(),
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
<PackageReference Remove="xunit" />
<PackageReference Remove="xunit.runner.visualstudio" />
<PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Remove="Mongo2Go" />
<PackageReference Remove="coverlet.collector" />
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
<Using Remove="StellaOps.Concelier.Testing" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Go\StellaOps.Scanner.Analyzers.Lang.Go.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Go;
public sealed class GoAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Go";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new GoLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,292 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Linq;
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Go;
public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "golang";
public string DisplayName => "Go Analyzer";
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath));
candidatePaths.Sort(StringComparer.Ordinal);
foreach (var absolutePath in candidatePaths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!GoBuildInfoProvider.TryGetBuildInfo(absolutePath, out var buildInfo) || buildInfo is null)
{
continue;
}
EmitComponents(buildInfo, context, writer);
}
return ValueTask.CompletedTask;
}
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer)
{
var components = new List<GoModule> { buildInfo.MainModule };
components.AddRange(buildInfo.Dependencies
.OrderBy(static module => module.Path, StringComparer.Ordinal)
.ThenBy(static module => module.Version, StringComparer.Ordinal));
string? binaryHash = null;
var binaryRelativePath = context.GetRelativePath(buildInfo.AbsoluteBinaryPath);
foreach (var module in components)
{
var metadata = BuildMetadata(buildInfo, module, binaryRelativePath);
var evidence = BuildEvidence(buildInfo, module, binaryRelativePath, context, ref binaryHash);
var usedByEntrypoint = module.IsMain && context.UsageHints.IsPathUsed(buildInfo.AbsoluteBinaryPath);
var purl = BuildPurl(module.Path, module.Version);
if (!string.IsNullOrEmpty(purl))
{
writer.AddFromPurl(
analyzerId: Id,
purl: purl,
name: module.Path,
version: module.Version,
type: "golang",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: usedByEntrypoint);
}
else
{
var componentKey = BuildFallbackComponentKey(module, buildInfo, binaryRelativePath, ref binaryHash);
writer.AddFromExplicitKey(
analyzerId: Id,
componentKey: componentKey,
purl: null,
name: module.Path,
version: module.Version,
type: "golang",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: usedByEntrypoint);
}
}
}
private static IEnumerable<KeyValuePair<string, string?>> BuildMetadata(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath)
{
var entries = new List<KeyValuePair<string, string?>>(16)
{
new("modulePath", module.Path),
new("binaryPath", string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath),
};
if (!string.IsNullOrEmpty(module.Version))
{
entries.Add(new KeyValuePair<string, string?>("moduleVersion", module.Version));
}
if (!string.IsNullOrEmpty(module.Sum))
{
entries.Add(new KeyValuePair<string, string?>("moduleSum", module.Sum));
}
if (module.Replacement is not null)
{
entries.Add(new KeyValuePair<string, string?>("replacedBy.path", module.Replacement.Path));
if (!string.IsNullOrEmpty(module.Replacement.Version))
{
entries.Add(new KeyValuePair<string, string?>("replacedBy.version", module.Replacement.Version));
}
if (!string.IsNullOrEmpty(module.Replacement.Sum))
{
entries.Add(new KeyValuePair<string, string?>("replacedBy.sum", module.Replacement.Sum));
}
}
if (module.IsMain)
{
entries.Add(new KeyValuePair<string, string?>("go.version", buildInfo.GoVersion));
entries.Add(new KeyValuePair<string, string?>("modulePath.main", buildInfo.ModulePath));
foreach (var setting in buildInfo.Settings)
{
var key = $"build.{setting.Key}";
if (!entries.Any(pair => string.Equals(pair.Key, key, StringComparison.Ordinal)))
{
entries.Add(new KeyValuePair<string, string?>(key, setting.Value));
}
}
if (buildInfo.DwarfMetadata is { } dwarf)
{
AddIfMissing(entries, "build.vcs", dwarf.VcsSystem);
AddIfMissing(entries, "build.vcs.revision", dwarf.Revision);
AddIfMissing(entries, "build.vcs.modified", dwarf.Modified?.ToString()?.ToLowerInvariant());
AddIfMissing(entries, "build.vcs.time", dwarf.TimestampUtc);
}
}
entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
return entries;
}
private static IEnumerable<LanguageComponentEvidence> BuildEvidence(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath, LanguageAnalyzerContext context, ref string? binaryHash)
{
var evidence = new List<LanguageComponentEvidence>
{
new(
LanguageEvidenceKind.Metadata,
"go.buildinfo",
$"module:{module.Path}",
module.Version ?? string.Empty,
module.Sum)
};
if (module.IsMain)
{
foreach (var setting in buildInfo.Settings)
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.buildinfo.setting",
setting.Key,
setting.Value,
null));
}
if (buildInfo.DwarfMetadata is { } dwarf)
{
if (!string.IsNullOrWhiteSpace(dwarf.VcsSystem))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs",
dwarf.VcsSystem,
null));
}
if (!string.IsNullOrWhiteSpace(dwarf.Revision))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs.revision",
dwarf.Revision,
null));
}
if (dwarf.Modified.HasValue)
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs.modified",
dwarf.Modified.Value ? "true" : "false",
null));
}
if (!string.IsNullOrWhiteSpace(dwarf.TimestampUtc))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs.time",
dwarf.TimestampUtc,
null));
}
}
}
// Attach binary hash evidence for fallback components without purl.
if (string.IsNullOrEmpty(module.Version))
{
binaryHash ??= ComputeBinaryHash(buildInfo.AbsoluteBinaryPath);
if (!string.IsNullOrEmpty(binaryHash))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"binary",
string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath,
null,
binaryHash));
}
}
evidence.Sort(static (left, right) => string.CompareOrdinal(left.ComparisonKey, right.ComparisonKey));
return evidence;
}
private static string? BuildPurl(string path, string? version)
{
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(version))
{
return null;
}
var cleanedPath = path.Trim();
var cleanedVersion = version.Trim();
var encodedVersion = Uri.EscapeDataString(cleanedVersion);
return $"pkg:golang/{cleanedPath}@{encodedVersion}";
}
private static string BuildFallbackComponentKey(GoModule module, GoBuildInfo buildInfo, string binaryRelativePath, ref string? binaryHash)
{
var relative = string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath;
binaryHash ??= ComputeBinaryHash(buildInfo.AbsoluteBinaryPath);
if (!string.IsNullOrEmpty(binaryHash))
{
return $"golang::module:{module.Path}::{relative}::{binaryHash}";
}
return $"golang::module:{module.Path}::{relative}";
}
private static void AddIfMissing(List<KeyValuePair<string, string?>> entries, string key, string? value)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
return;
}
if (entries.Any(entry => string.Equals(entry.Key, key, StringComparison.Ordinal)))
{
return;
}
entries.Add(new KeyValuePair<string, string?>(key, value));
}
private static string? ComputeBinaryHash(string path)
{
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBinaryScanner
{
private static readonly ReadOnlyMemory<byte> BuildInfoMagic = new byte[]
{
0xFF, (byte)' ', (byte)'G', (byte)'o', (byte)' ', (byte)'b', (byte)'u', (byte)'i', (byte)'l', (byte)'d', (byte)'i', (byte)'n', (byte)'f', (byte)':'
};
public static IEnumerable<string> EnumerateCandidateFiles(string rootPath)
{
var enumeration = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
MatchCasing = MatchCasing.CaseSensitive,
};
foreach (var path in Directory.EnumerateFiles(rootPath, "*", enumeration))
{
yield return path;
}
}
public static bool TryReadBuildInfo(string filePath, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
try
{
var info = new FileInfo(filePath);
if (!info.Exists || info.Length < 64 || info.Length > 128 * 1024 * 1024)
{
return false;
}
var data = File.ReadAllBytes(filePath);
var span = new ReadOnlySpan<byte>(data);
var offset = span.IndexOf(BuildInfoMagic.Span);
if (offset < 0)
{
return false;
}
var view = span[offset..];
return GoBuildInfoDecoder.TryDecode(view, out goVersion, out moduleData);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoBuildInfo
{
public GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
IEnumerable<GoModule> dependencies,
IEnumerable<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata = null)
: this(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies?
.Where(static module => module is not null)
.ToImmutableArray()
?? ImmutableArray<GoModule>.Empty,
settings?
.Where(static pair => pair.Key is not null)
.Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value))
.ToImmutableArray()
?? ImmutableArray<KeyValuePair<string, string?>>.Empty,
dwarfMetadata)
{
}
private GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
ImmutableArray<GoModule> dependencies,
ImmutableArray<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata)
{
GoVersion = goVersion ?? throw new ArgumentNullException(nameof(goVersion));
AbsoluteBinaryPath = absoluteBinaryPath ?? throw new ArgumentNullException(nameof(absoluteBinaryPath));
ModulePath = modulePath ?? throw new ArgumentNullException(nameof(modulePath));
MainModule = mainModule ?? throw new ArgumentNullException(nameof(mainModule));
Dependencies = dependencies;
Settings = settings;
DwarfMetadata = dwarfMetadata;
}
public string GoVersion { get; }
public string AbsoluteBinaryPath { get; }
public string ModulePath { get; }
public GoModule MainModule { get; }
public ImmutableArray<GoModule> Dependencies { get; }
public ImmutableArray<KeyValuePair<string, string?>> Settings { get; }
public GoDwarfMetadata? DwarfMetadata { get; }
public GoBuildInfo WithDwarf(GoDwarfMetadata metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
return new GoBuildInfo(
GoVersion,
AbsoluteBinaryPath,
ModulePath,
MainModule,
Dependencies,
Settings,
metadata);
}
}

View File

@@ -0,0 +1,159 @@
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoDecoder
{
private const string BuildInfoMagic = "\xff Go buildinf:";
private const int HeaderSize = 32;
private const byte VarintEncodingFlag = 0x02;
public static bool TryDecode(ReadOnlySpan<byte> data, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
if (data.Length < HeaderSize)
{
return false;
}
if (!IsMagicMatch(data))
{
return false;
}
var pointerSize = data[14];
var flags = data[15];
if (pointerSize != 4 && pointerSize != 8)
{
return false;
}
if ((flags & VarintEncodingFlag) == 0)
{
// Older Go toolchains encode pointers to strings instead of inline data.
// The Sprint 10 scope targets Go 1.18+, which always sets the varint flag.
return false;
}
var payload = data.Slice(HeaderSize);
if (!TryReadVarString(payload, out var version, out var consumed))
{
return false;
}
payload = payload.Slice(consumed);
if (!TryReadVarString(payload, out var modules, out _))
{
return false;
}
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
modules = StripSentinel(modules);
goVersion = version;
moduleData = modules;
return !string.IsNullOrWhiteSpace(moduleData);
}
private static bool IsMagicMatch(ReadOnlySpan<byte> data)
{
if (data.Length < BuildInfoMagic.Length)
{
return false;
}
for (var i = 0; i < BuildInfoMagic.Length; i++)
{
if (data[i] != BuildInfoMagic[i])
{
return false;
}
}
return true;
}
private static bool TryReadVarString(ReadOnlySpan<byte> data, out string result, out int consumed)
{
result = string.Empty;
consumed = 0;
if (!TryReadUVarint(data, out var length, out var lengthBytes))
{
return false;
}
if (length > int.MaxValue)
{
return false;
}
var stringLength = (int)length;
var totalRequired = lengthBytes + stringLength;
if (stringLength <= 0 || totalRequired > data.Length)
{
return false;
}
var slice = data.Slice(lengthBytes, stringLength);
result = Encoding.UTF8.GetString(slice);
consumed = totalRequired;
return true;
}
private static bool TryReadUVarint(ReadOnlySpan<byte> data, out ulong value, out int bytesRead)
{
value = 0;
bytesRead = 0;
ulong x = 0;
var shift = 0;
for (var i = 0; i < data.Length; i++)
{
var b = data[i];
if (b < 0x80)
{
if (i > 9 || i == 9 && b > 1)
{
return false;
}
value = x | (ulong)b << shift;
bytesRead = i + 1;
return true;
}
x |= (ulong)(b & 0x7F) << shift;
shift += 7;
}
return false;
}
private static string StripSentinel(string value)
{
if (string.IsNullOrEmpty(value) || value.Length < 33)
{
return value;
}
var sentinelIndex = value.Length - 17;
if (value[sentinelIndex] != '\n')
{
return value;
}
return value[16..^16];
}
}

View File

@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoParser
{
private const string PathPrefix = "path\t";
private const string ModulePrefix = "mod\t";
private const string DependencyPrefix = "dep\t";
private const string ReplacementPrefix = "=>\t";
private const string BuildPrefix = "build\t";
public static bool TryParse(string goVersion, string absoluteBinaryPath, string rawModuleData, out GoBuildInfo? info)
{
info = null;
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(rawModuleData))
{
return false;
}
string? modulePath = null;
GoModule? mainModule = null;
var dependencies = new List<GoModule>();
var settings = new SortedDictionary<string, string?>(StringComparer.Ordinal);
GoModule? lastModule = null;
using var reader = new StringReader(rawModuleData);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (line.StartsWith(PathPrefix, StringComparison.Ordinal))
{
modulePath = line[PathPrefix.Length..].Trim();
continue;
}
if (line.StartsWith(ModulePrefix, StringComparison.Ordinal))
{
mainModule = ParseModule(line.AsSpan(ModulePrefix.Length), isMain: true);
lastModule = mainModule;
continue;
}
if (line.StartsWith(DependencyPrefix, StringComparison.Ordinal))
{
var dependency = ParseModule(line.AsSpan(DependencyPrefix.Length), isMain: false);
if (dependency is not null)
{
dependencies.Add(dependency);
lastModule = dependency;
}
continue;
}
if (line.StartsWith(ReplacementPrefix, StringComparison.Ordinal))
{
if (lastModule is null)
{
continue;
}
var replacement = ParseReplacement(line.AsSpan(ReplacementPrefix.Length));
if (replacement is not null)
{
lastModule.SetReplacement(replacement);
}
continue;
}
if (line.StartsWith(BuildPrefix, StringComparison.Ordinal))
{
var pair = ParseBuildSetting(line.AsSpan(BuildPrefix.Length));
if (!string.IsNullOrEmpty(pair.Key))
{
settings[pair.Key] = pair.Value;
}
}
}
if (mainModule is null)
{
return false;
}
if (string.IsNullOrEmpty(modulePath))
{
modulePath = mainModule.Path;
}
info = new GoBuildInfo(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies,
settings);
return true;
}
private static GoModule? ParseModule(ReadOnlySpan<char> span, bool isMain)
{
var fields = SplitFields(span, expected: 4);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModule(path, version, sum, isMain);
}
private static GoModuleReplacement? ParseReplacement(ReadOnlySpan<char> span)
{
var fields = SplitFields(span, expected: 3);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModuleReplacement(path, version, sum);
}
private static KeyValuePair<string, string?> ParseBuildSetting(ReadOnlySpan<char> span)
{
span = span.Trim();
if (span.IsEmpty)
{
return default;
}
var separatorIndex = span.IndexOf('=');
if (separatorIndex <= 0)
{
return default;
}
var rawKey = span[..separatorIndex].Trim();
var rawValue = span[(separatorIndex + 1)..].Trim();
var key = Unquote(rawKey.ToString());
if (string.IsNullOrWhiteSpace(key))
{
return default;
}
var value = Unquote(rawValue.ToString());
return new KeyValuePair<string, string?>(key, value);
}
private static List<string> SplitFields(ReadOnlySpan<char> span, int expected)
{
var fields = new List<string>(expected);
var builder = new StringBuilder();
for (var i = 0; i < span.Length; i++)
{
var current = span[i];
if (current == '\t')
{
fields.Add(builder.ToString());
builder.Clear();
continue;
}
builder.Append(current);
}
fields.Add(builder.ToString());
return fields;
}
private static string Unquote(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
value = value.Trim();
if (value.Length < 2)
{
return value;
}
if (value[0] == '"' && value[^1] == '"')
{
try
{
return JsonSerializer.Deserialize<string>(value) ?? value;
}
catch (JsonException)
{
return value;
}
}
if (value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
return value;
}
}

View File

@@ -0,0 +1,82 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoProvider
{
private static readonly ConcurrentDictionary<GoBinaryCacheKey, GoBuildInfo?> Cache = new();
public static bool TryGetBuildInfo(string absolutePath, out GoBuildInfo? info)
{
info = null;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(absolutePath);
if (!fileInfo.Exists)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
info = Cache.GetOrAdd(key, static (cacheKey, path) => CreateBuildInfo(path), absolutePath);
return info is not null;
}
private static GoBuildInfo? CreateBuildInfo(string absolutePath)
{
if (!GoBinaryScanner.TryReadBuildInfo(absolutePath, out var goVersion, out var moduleData))
{
return null;
}
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(moduleData))
{
return null;
}
if (!GoBuildInfoParser.TryParse(goVersion!, absolutePath, moduleData!, out var buildInfo) || buildInfo is null)
{
return null;
}
if (GoDwarfReader.TryRead(absolutePath, out var dwarf) && dwarf is not null)
{
buildInfo = buildInfo.WithDwarf(dwarf);
}
return buildInfo;
}
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
: Path;
public bool Equals(GoBinaryCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
}
}

View File

@@ -0,0 +1,33 @@
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoDwarfMetadata
{
public GoDwarfMetadata(string? vcsSystem, string? revision, bool? modified, string? timestampUtc)
{
VcsSystem = Normalize(vcsSystem);
Revision = Normalize(revision);
Modified = modified;
TimestampUtc = Normalize(timestampUtc);
}
public string? VcsSystem { get; }
public string? Revision { get; }
public bool? Modified { get; }
public string? TimestampUtc { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoDwarfReader
{
private static readonly byte[] VcsSystemToken = Encoding.UTF8.GetBytes("vcs=");
private static readonly byte[] VcsRevisionToken = Encoding.UTF8.GetBytes("vcs.revision=");
private static readonly byte[] VcsModifiedToken = Encoding.UTF8.GetBytes("vcs.modified=");
private static readonly byte[] VcsTimeToken = Encoding.UTF8.GetBytes("vcs.time=");
public static bool TryRead(string path, out GoDwarfMetadata? metadata)
{
metadata = null;
ReadOnlySpan<byte> data;
try
{
var fileInfo = new FileInfo(path);
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > 256 * 1024 * 1024)
{
return false;
}
data = File.ReadAllBytes(path);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
var revision = ExtractValue(data, VcsRevisionToken);
var modifiedText = ExtractValue(data, VcsModifiedToken);
var timestamp = ExtractValue(data, VcsTimeToken);
var system = ExtractValue(data, VcsSystemToken);
bool? modified = null;
if (!string.IsNullOrWhiteSpace(modifiedText))
{
if (bool.TryParse(modifiedText, out var parsed))
{
modified = parsed;
}
}
if (string.IsNullOrWhiteSpace(revision) && string.IsNullOrWhiteSpace(system) && modified is null && string.IsNullOrWhiteSpace(timestamp))
{
return false;
}
metadata = new GoDwarfMetadata(system, revision, modified, timestamp);
return true;
}
private static string? ExtractValue(ReadOnlySpan<byte> data, ReadOnlySpan<byte> token)
{
var index = data.IndexOf(token);
if (index < 0)
{
return null;
}
var start = index + token.Length;
var end = start;
while (end < data.Length)
{
var current = data[end];
if (current == 0 || current == (byte)'\n' || current == (byte)'\r')
{
break;
}
end++;
}
if (end <= start)
{
return null;
}
return Encoding.UTF8.GetString(data.Slice(start, end - start));
}
}

View File

@@ -0,0 +1,67 @@
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoModule
{
public GoModule(string path, string? version, string? sum, bool isMain)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
IsMain = isMain;
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
public GoModuleReplacement? Replacement { get; private set; }
public bool IsMain { get; }
public void SetReplacement(GoModuleReplacement replacement)
{
Replacement = replacement ?? throw new ArgumentNullException(nameof(replacement));
}
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
internal sealed class GoModuleReplacement
{
public GoModuleReplacement(string path, string? version, string? sum)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.Scanner.Analyzers.Lang.Go;
internal static class Placeholder
{
// Analyzer implementation will be added during Sprint LA3.
}

View File

@@ -2,8 +2,8 @@
| Seq | ID | Status | Depends on | Description | Exit Criteria |
|-----|----|--------|------------|-------------|---------------|
| 1 | SCANNER-ANALYZERS-LANG-10-304A | TODO | SCANNER-ANALYZERS-LANG-10-307 | Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence. | Build info extracted across Go 1.181.23 fixtures; evidence includes VCS, module path, and build settings. |
| 2 | SCANNER-ANALYZERS-LANG-10-304B | TODO | SCANNER-ANALYZERS-LANG-10-304A | Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries. | DWARF reader supplies commit hash for ≥95% fixtures; cache reduces duplicated IO by ≥70%. |
| 1 | SCANNER-ANALYZERS-LANG-10-304A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence. | Build info extracted across Go 1.181.23 fixtures; evidence includes VCS, module path, and build settings. |
| 2 | SCANNER-ANALYZERS-LANG-10-304B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304A | Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries. | DWARF reader supplies commit hash for ≥95% fixtures; cache reduces duplicated IO by ≥70%. |
| 3 | SCANNER-ANALYZERS-LANG-10-304C | TODO | SCANNER-ANALYZERS-LANG-10-304B | Fallback heuristics for stripped binaries with deterministic `bin:{sha256}` labeling and quiet provenance. | Heuristic labels clearly separated; tests ensure no false “observed” provenance; documentation updated. |
| 4 | SCANNER-ANALYZERS-LANG-10-307G | TODO | SCANNER-ANALYZERS-LANG-10-304C | Wire shared helpers (license mapping, usage flags) and ensure concurrency-safe buffer reuse. | Analyzer reuses shared infrastructure; concurrency tests with parallel scans pass; no data races. |
| 5 | SCANNER-ANALYZERS-LANG-10-308G | TODO | SCANNER-ANALYZERS-LANG-10-307G | Determinism fixtures + benchmark harness (Vs competitor). | Fixtures under `Fixtures/lang/go/`; CI determinism check; benchmark runs showing ≥20% speed advantage. |

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.go",
"displayName": "StellaOps Go Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Go.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Go.GoAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"golang",
"go"
],
"metadata": {
"org.stellaops.analyzer.language": "go",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}

View File

@@ -1,35 +1,35 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com/example/demo@1.0.0",
"purl": "pkg:maven/com/example/demo@1.0.0",
"name": "demo",
"version": "1.0.0",
"type": "maven",
"usedByEntrypoint": true,
"metadata": {
"artifactId": "demo",
"displayName": "Demo Library",
"groupId": "com.example",
"jarPath": "libs/demo.jar",
"manifestTitle": "Demo",
"manifestVendor": "Example Corp",
"manifestVersion": "1.0.0",
"packaging": "jar"
},
"evidence": [
{
"kind": "file",
"source": "MANIFEST.MF",
"locator": "libs/demo.jar!META-INF/MANIFEST.MF",
"value": "title=Demo;version=1.0.0;vendor=Example Corp"
},
{
"kind": "file",
"source": "pom.properties",
"locator": "libs/demo.jar!META-INF/maven/com.example/demo/pom.properties",
"sha256": "c20f36aa1b9d89d28cf9ed131519ffd6287a4dac0c7cb926130496f3f8157bf1"
}
]
}
]
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com/example/demo@1.0.0",
"purl": "pkg:maven/com/example/demo@1.0.0",
"name": "demo",
"version": "1.0.0",
"type": "maven",
"usedByEntrypoint": true,
"metadata": {
"artifactId": "demo",
"displayName": "Demo Library",
"groupId": "com.example",
"jarPath": "libs/demo.jar",
"manifestTitle": "Demo",
"manifestVendor": "Example Corp",
"manifestVersion": "1.0.0",
"packaging": "jar"
},
"evidence": [
{
"kind": "file",
"source": "MANIFEST.MF",
"locator": "libs/demo.jar!META-INF/MANIFEST.MF",
"value": "title=Demo;version=1.0.0;vendor=Example Corp"
},
{
"kind": "file",
"source": "pom.properties",
"locator": "libs/demo.jar!META-INF/maven/com.example/demo/pom.properties",
"sha256": "c20f36aa1b9d89d28cf9ed131519ffd6287a4dac0c7cb926130496f3f8157bf1"
}
]
}
]

View File

@@ -1,33 +1,33 @@
using StellaOps.Scanner.Analyzers.Lang.Java;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Tests.Java;
public sealed class JavaLanguageAnalyzerTests
{
[Fact]
public async Task ExtractsMavenArtifactFromJarAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = JavaFixtureBuilder.CreateSampleJar(root);
var usageHints = new LanguageUsageHints(new[] { jarPath });
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var goldenPath = TestPaths.ResolveFixture("java", "basic", "expected.json");
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath: root,
goldenPath: goldenPath,
analyzers: analyzers,
cancellationToken: cancellationToken,
usageHints: usageHints);
}
finally
{
TestPaths.SafeDelete(root);
}
}
}
using StellaOps.Scanner.Analyzers.Lang.Java;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests;
public sealed class JavaLanguageAnalyzerTests
{
[Fact]
public async Task ExtractsMavenArtifactFromJarAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = JavaFixtureBuilder.CreateSampleJar(root);
var usageHints = new LanguageUsageHints(new[] { jarPath });
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var goldenPath = TestPaths.ResolveFixture("java", "basic", "expected.json");
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath: root,
goldenPath: goldenPath,
analyzers: analyzers,
cancellationToken: cancellationToken,
usageHints: usageHints);
}
finally
{
TestPaths.SafeDelete(root);
}
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
<PackageReference Remove="xunit" />
<PackageReference Remove="xunit.runner.visualstudio" />
<PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Remove="Mongo2Go" />
<PackageReference Remove="coverlet.collector" />
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
<Using Remove="StellaOps.Concelier.Testing" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Java\StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -1,134 +1,134 @@
[
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/left-pad@1.3.0",
"purl": "pkg:npm/left-pad@1.3.0",
"name": "left-pad",
"version": "1.3.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"integrity": "sha512-LEFTPAD",
"path": "packages/app/node_modules/left-pad",
"resolved": "https://registry.example/left-pad-1.3.0.tgz"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/node_modules/left-pad/package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/lib@2.0.1",
"purl": "pkg:npm/lib@2.0.1",
"name": "lib",
"version": "2.0.1",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"integrity": "sha512-LIB",
"path": "packages/lib",
"resolved": "https://registry.example/lib-2.0.1.tgz",
"workspaceLink": "packages/app/node_modules/lib",
"workspaceMember": "true",
"workspaceRoot": "packages/lib"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/node_modules/lib/package.json"
},
{
"kind": "file",
"source": "package.json",
"locator": "packages/lib/package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/root-workspace@1.0.0",
"purl": "pkg:npm/root-workspace@1.0.0",
"name": "root-workspace",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"path": ".",
"private": "true"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/shared@3.1.4",
"purl": "pkg:npm/shared@3.1.4",
"name": "shared",
"version": "3.1.4",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"integrity": "sha512-SHARED",
"path": "packages/shared",
"resolved": "https://registry.example/shared-3.1.4.tgz",
"workspaceLink": "packages/app/node_modules/shared",
"workspaceMember": "true",
"workspaceRoot": "packages/shared",
"workspaceTargets": "packages/lib"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/node_modules/shared/package.json"
},
{
"kind": "file",
"source": "package.json",
"locator": "packages/shared/package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/workspace-app@1.0.0",
"purl": "pkg:npm/workspace-app@1.0.0",
"name": "workspace-app",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"installScripts": "true",
"path": "packages/app",
"policyHint.installLifecycle": "postinstall",
"script.postinstall": "node scripts/setup.js",
"workspaceMember": "true",
"workspaceRoot": "packages/app",
"workspaceTargets": "packages/lib;packages/shared"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/package.json"
},
{
"kind": "metadata",
"source": "package.json:scripts",
"locator": "packages/app/package.json#scripts.postinstall",
"value": "node scripts/setup.js",
"sha256": "f9ae4e4c9313857d1acc31947cee9984232cbefe93c8a56c718804744992728a"
}
]
}
]
[
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/left-pad@1.3.0",
"purl": "pkg:npm/left-pad@1.3.0",
"name": "left-pad",
"version": "1.3.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"integrity": "sha512-LEFTPAD",
"path": "packages/app/node_modules/left-pad",
"resolved": "https://registry.example/left-pad-1.3.0.tgz"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/node_modules/left-pad/package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/lib@2.0.1",
"purl": "pkg:npm/lib@2.0.1",
"name": "lib",
"version": "2.0.1",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"integrity": "sha512-LIB",
"path": "packages/lib",
"resolved": "https://registry.example/lib-2.0.1.tgz",
"workspaceLink": "packages/app/node_modules/lib",
"workspaceMember": "true",
"workspaceRoot": "packages/lib"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/node_modules/lib/package.json"
},
{
"kind": "file",
"source": "package.json",
"locator": "packages/lib/package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/root-workspace@1.0.0",
"purl": "pkg:npm/root-workspace@1.0.0",
"name": "root-workspace",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"path": ".",
"private": "true"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/shared@3.1.4",
"purl": "pkg:npm/shared@3.1.4",
"name": "shared",
"version": "3.1.4",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"integrity": "sha512-SHARED",
"path": "packages/shared",
"resolved": "https://registry.example/shared-3.1.4.tgz",
"workspaceLink": "packages/app/node_modules/shared",
"workspaceMember": "true",
"workspaceRoot": "packages/shared",
"workspaceTargets": "packages/lib"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/node_modules/shared/package.json"
},
{
"kind": "file",
"source": "package.json",
"locator": "packages/shared/package.json"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/workspace-app@1.0.0",
"purl": "pkg:npm/workspace-app@1.0.0",
"name": "workspace-app",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"installScripts": "true",
"path": "packages/app",
"policyHint.installLifecycle": "postinstall",
"script.postinstall": "node scripts/setup.js",
"workspaceMember": "true",
"workspaceRoot": "packages/app",
"workspaceTargets": "packages/lib;packages/shared"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "packages/app/package.json"
},
{
"kind": "metadata",
"source": "package.json:scripts",
"locator": "packages/app/package.json#scripts.postinstall",
"value": "node scripts/setup.js",
"sha256": "f9ae4e4c9313857d1acc31947cee9984232cbefe93c8a56c718804744992728a"
}
]
}
]

View File

@@ -1,49 +1,49 @@
{
"name": "root-workspace",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "root-workspace",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
]
},
"packages/app": {
"name": "workspace-app",
"version": "1.0.0"
},
"packages/lib": {
"name": "lib",
"version": "2.0.1",
"resolved": "https://registry.example/lib-2.0.1.tgz",
"integrity": "sha512-LIB"
},
"packages/shared": {
"name": "shared",
"version": "3.1.4",
"resolved": "https://registry.example/shared-3.1.4.tgz",
"integrity": "sha512-SHARED"
},
"packages/app/node_modules/lib": {
"name": "lib",
"version": "2.0.1",
"resolved": "https://registry.example/lib-2.0.1.tgz",
"integrity": "sha512-LIB"
},
"packages/app/node_modules/shared": {
"name": "shared",
"version": "3.1.4",
"resolved": "https://registry.example/shared-3.1.4.tgz",
"integrity": "sha512-SHARED"
},
"packages/app/node_modules/left-pad": {
"name": "left-pad",
"version": "1.3.0",
"resolved": "https://registry.example/left-pad-1.3.0.tgz",
"integrity": "sha512-LEFTPAD"
}
}
}
{
"name": "root-workspace",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "root-workspace",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
]
},
"packages/app": {
"name": "workspace-app",
"version": "1.0.0"
},
"packages/lib": {
"name": "lib",
"version": "2.0.1",
"resolved": "https://registry.example/lib-2.0.1.tgz",
"integrity": "sha512-LIB"
},
"packages/shared": {
"name": "shared",
"version": "3.1.4",
"resolved": "https://registry.example/shared-3.1.4.tgz",
"integrity": "sha512-SHARED"
},
"packages/app/node_modules/lib": {
"name": "lib",
"version": "2.0.1",
"resolved": "https://registry.example/lib-2.0.1.tgz",
"integrity": "sha512-LIB"
},
"packages/app/node_modules/shared": {
"name": "shared",
"version": "3.1.4",
"resolved": "https://registry.example/shared-3.1.4.tgz",
"integrity": "sha512-SHARED"
},
"packages/app/node_modules/left-pad": {
"name": "left-pad",
"version": "1.3.0",
"resolved": "https://registry.example/left-pad-1.3.0.tgz",
"integrity": "sha512-LEFTPAD"
}
}
}

View File

@@ -1,10 +1,10 @@
{
"name": "root-workspace",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/app",
"packages/lib",
"packages/shared"
]
}
{
"name": "root-workspace",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/app",
"packages/lib",
"packages/shared"
]
}

View File

@@ -1,5 +1,5 @@
{
"name": "left-pad",
"version": "1.3.0",
"main": "index.js"
}
{
"name": "left-pad",
"version": "1.3.0",
"main": "index.js"
}

View File

@@ -1,5 +1,5 @@
{
"name": "lib",
"version": "2.0.1",
"main": "index.js"
}
{
"name": "lib",
"version": "2.0.1",
"main": "index.js"
}

View File

@@ -1,5 +1,5 @@
{
"name": "shared",
"version": "3.1.4",
"main": "index.js"
}
{
"name": "shared",
"version": "3.1.4",
"main": "index.js"
}

View File

@@ -1,11 +1,11 @@
{
"name": "workspace-app",
"version": "1.0.0",
"dependencies": {
"lib": "workspace:../lib",
"shared": "workspace:../shared"
},
"scripts": {
"postinstall": "node scripts/setup.js"
}
}
{
"name": "workspace-app",
"version": "1.0.0",
"dependencies": {
"lib": "workspace:../lib",
"shared": "workspace:../shared"
},
"scripts": {
"postinstall": "node scripts/setup.js"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "lib",
"version": "2.0.1",
"dependencies": {
"left-pad": "1.3.0"
}
}
{
"name": "lib",
"version": "2.0.1",
"dependencies": {
"left-pad": "1.3.0"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "shared",
"version": "3.1.4",
"dependencies": {
"lib": "workspace:../lib"
}
}
{
"name": "shared",
"version": "3.1.4",
"dependencies": {
"lib": "workspace:../lib"
}
}

View File

@@ -1,27 +1,27 @@
using StellaOps.Scanner.Analyzers.Lang.Node;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Tests.Node;
public sealed class NodeLanguageAnalyzerTests
{
[Fact]
public async Task WorkspaceFixtureProducesDeterministicOutputAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "workspaces");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new NodeLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
}
using StellaOps.Scanner.Analyzers.Lang.Node;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Tests;
public sealed class NodeLanguageAnalyzerTests
{
[Fact]
public async Task WorkspaceFixtureProducesDeterministicOutputAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "workspaces");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new NodeLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
<PackageReference Remove="xunit" />
<PackageReference Remove="xunit.runner.visualstudio" />
<PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Remove="Mongo2Go" />
<PackageReference Remove="coverlet.collector" />
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
<Using Remove="StellaOps.Concelier.Testing" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Node\StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,18 @@
using System;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Node;
public sealed class NodeAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Node";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new NodeLanguageAnalyzer();
}
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.Scanner.Analyzers.Lang.Node;
internal static class Placeholder
{
// Analyzer implementation will be added during Sprint LA1.
}

View File

@@ -5,6 +5,6 @@
| 1 | SCANNER-ANALYZERS-LANG-10-302A | DONE (2025-10-19) | SCANNER-ANALYZERS-LANG-10-307 | Build deterministic module graph walker covering npm, Yarn, and PNPM; capture package.json provenance and integrity metadata. | Walker indexes >100k modules in <1.5s (hot cache); golden fixtures verify deterministic ordering and path normalization. |
| 2 | SCANNER-ANALYZERS-LANG-10-302B | DONE (2025-10-19) | SCANNER-ANALYZERS-LANG-10-302A | Resolve workspaces/symlinks and attribute components to originating package with usage hints; guard against directory traversal. | Workspace attribution accurate on multi-workspace fixture; symlink resolver proves canonical path; security tests ensure no traversal. |
| 3 | SCANNER-ANALYZERS-LANG-10-302C | DONE (2025-10-19) | SCANNER-ANALYZERS-LANG-10-302B | Surface script metadata (postinstall/preinstall) and policy hints; emit telemetry counters and evidence records. | Analyzer output includes script metadata + evidence; metrics `scanner_analyzer_node_scripts_total` recorded; policy hints documented. |
| 4 | SCANNER-ANALYZERS-LANG-10-307N | TODO | SCANNER-ANALYZERS-LANG-10-302C | Integrate shared helpers for license/licence evidence, canonical JSON serialization, and usage flag propagation. | Reuse shared helpers without duplication; unit tests confirm stable metadata merge; no analyzer-specific serializer drift. |
| 5 | SCANNER-ANALYZERS-LANG-10-308N | TODO | SCANNER-ANALYZERS-LANG-10-307N | Author determinism harness + fixtures for Node analyzer; add benchmark suite. | Fixtures committed under `Fixtures/lang/node/`; determinism CI job compares JSON snapshots; benchmark CSV published. |
| 6 | SCANNER-ANALYZERS-LANG-10-309N | TODO | SCANNER-ANALYZERS-LANG-10-308N | Package Node analyzer as restart-time plug-in (manifest, DI registration, Offline Kit notes). | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer after restart; Offline Kit docs updated. |
| 4 | SCANNER-ANALYZERS-LANG-10-307N | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-302C | Integrate shared helpers for license/licence evidence, canonical JSON serialization, and usage flag propagation. | Reuse shared helpers without duplication; unit tests confirm stable metadata merge; no analyzer-specific serializer drift. |
| 5 | SCANNER-ANALYZERS-LANG-10-308N | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-307N | Author determinism harness + fixtures for Node analyzer; add benchmark suite. | Fixtures committed under `Fixtures/lang/node/`; determinism CI job compares JSON snapshots; benchmark CSV published. |
| 6 | SCANNER-ANALYZERS-LANG-10-309N | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-308N | Package Node analyzer as restart-time plug-in (manifest, DI registration, Offline Kit notes). | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer after restart; Offline Kit docs updated. |

View File

@@ -0,0 +1,22 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.node",
"displayName": "StellaOps Node.js Analyzer",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Node.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Node.NodeAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"node",
"npm"
],
"metadata": {
"org.stellaops.analyzer.language": "node",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true"
}
}

View File

@@ -0,0 +1,64 @@
[
{
"analyzerId": "python",
"componentKey": "purl::pkg:pypi/simple@1.0.0",
"purl": "pkg:pypi/simple@1.0.0",
"name": "simple",
"version": "1.0.0",
"type": "pypi",
"usedByEntrypoint": true,
"metadata": {
"author": "Example Dev",
"authorEmail": "dev@example.com",
"classifiers": "Programming Language :: Python :: 3;License :: OSI Approved :: Apache Software License",
"distInfoPath": "lib/python3.11/site-packages/simple-1.0.0.dist-info",
"editable": "true",
"entryPoints.console_scripts": "simple-tool=simple.core:main",
"homePage": "https://example.com/simple",
"installer": "pip",
"license": "Apache-2.0",
"name": "simple",
"projectUrl": "Source, https://example.com/simple/src",
"record.hashMismatches": "0",
"record.hashedEntries": "9",
"record.ioErrors": "0",
"record.missingFiles": "0",
"record.totalEntries": "10",
"requiresDist": "requests (\u003E=2.0)",
"requiresPython": "\u003E=3.9",
"sourceCommit": "abc123def",
"sourceSubdirectory": "src/simple",
"sourceUrl": "https://example.com/simple-1.0.0.tar.gz",
"sourceVcs": "git",
"summary": "Simple fixture package",
"version": "1.0.0",
"wheel.generator": "pip 24.0",
"wheel.rootIsPurelib": "true",
"wheel.tags": "py3-none-any",
"wheel.version": "1.0"
},
"evidence": [
{
"kind": "file",
"source": "METADATA",
"locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/METADATA"
},
{
"kind": "file",
"source": "WHEEL",
"locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/WHEEL"
},
{
"kind": "file",
"source": "entry_points.txt",
"locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/entry_points.txt"
},
{
"kind": "metadata",
"source": "direct_url.json",
"locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/direct_url.json",
"value": "https://example.com/simple-1.0.0.tar.gz"
}
]
}
]

View File

@@ -0,0 +1,13 @@
Metadata-Version: 2.1
Name: simple
Version: 1.0.0
Summary: Simple fixture package
Home-page: https://example.com/simple
Author: Example Dev
Author-email: dev@example.com
License: Apache-2.0
Project-URL: Source, https://example.com/simple/src
Requires-Python: >=3.9
Requires-Dist: requests (>=2.0)
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: Apache Software License

View File

@@ -0,0 +1,10 @@
simple/__init__.py,sha256=03NWG/tm5eky+tnGlynp/vcyjtR944EtQMKtwrutl/U=,79
simple/__main__.py,sha256=7pHsIZX9uNTyp1e1AkmZ1vXZxw/dYB1TbR1rYDJca6c=,62
simple/core.py,sha256=8HaF+vPTo2roSP7kivqePnjG+d/WqotH269Qey/BM+s=,67
../../../bin/simple-tool,sha256=E7eVnffg2E4646m1Ml/5ixyROcpc24GJvy03sEkg6DA=,91
simple-1.0.0.dist-info/METADATA,sha256=Da/AG+nYa85WfbUSNmmRjpTeEEM8Kinf6Z197xb8X2o=,408
simple-1.0.0.dist-info/WHEEL,sha256=m8MHT7vQnqC5W8H/y4uJdEpx9ijH0jJGpuoaxRbcsQg=,79
simple-1.0.0.dist-info/entry_points.txt,sha256=A2WCkblioa0YbdUDurLzv+sIbx7TRSJ9zfBLYMYwpBQ=,49
simple-1.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ+UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg=,4
simple-1.0.0.dist-info/direct_url.json,sha256=EXd4Xj5iohEIqiF7mlR7sCLGhqXiU1/LuOPjijstKCU=,199
simple-1.0.0.dist-info/RECORD,,

View File

@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: pip 24.0
Root-Is-Purelib: true
Tag: py3-none-any

View File

@@ -0,0 +1,11 @@
{
"url": "https://example.com/simple-1.0.0.tar.gz",
"dir_info": {
"editable": true,
"subdirectory": "src/simple"
},
"vcs_info": {
"vcs": "git",
"commit_id": "abc123def"
}
}

View File

@@ -0,0 +1,4 @@
__all__ = ["main"]
__version__ = "1.0.0"
from .core import main # noqa: F401

View File

@@ -0,0 +1,4 @@
from .core import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,4 @@
import sys
def main() -> None:
print("simple core", sys.argv)

View File

@@ -0,0 +1,33 @@
using StellaOps.Scanner.Analyzers.Lang.Python;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests;
public sealed class PythonLanguageAnalyzerTests
{
[Fact]
public async Task SimpleVenvFixtureProducesDeterministicOutputAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "python", "simple-venv");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var usageHints = new LanguageUsageHints(new[]
{
Path.Combine(fixturePath, "bin", "simple-tool")
});
var analyzers = new ILanguageAnalyzer[]
{
new PythonLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken,
usageHints);
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
<PackageReference Remove="xunit" />
<PackageReference Remove="xunit.runner.visualstudio" />
<PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Remove="Mongo2Go" />
<PackageReference Remove="coverlet.collector" />
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
<Using Remove="StellaOps.Concelier.Testing" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Python\StellaOps.Scanner.Analyzers.Lang.Python.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -0,0 +1,989 @@
using System.Buffers;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal;
internal static class PythonDistributionLoader
{
public static async Task<PythonDistribution?> LoadAsync(LanguageAnalyzerContext context, string distInfoPath, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(distInfoPath) || !Directory.Exists(distInfoPath))
{
return null;
}
var metadataPath = Path.Combine(distInfoPath, "METADATA");
var wheelPath = Path.Combine(distInfoPath, "WHEEL");
var entryPointsPath = Path.Combine(distInfoPath, "entry_points.txt");
var recordPath = Path.Combine(distInfoPath, "RECORD");
var installerPath = Path.Combine(distInfoPath, "INSTALLER");
var directUrlPath = Path.Combine(distInfoPath, "direct_url.json");
var metadataDocument = await PythonMetadataDocument.LoadAsync(metadataPath, cancellationToken).ConfigureAwait(false);
var name = metadataDocument.GetFirst("Name") ?? ExtractNameFromDirectory(distInfoPath);
var version = metadataDocument.GetFirst("Version") ?? ExtractVersionFromDirectory(distInfoPath);
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version))
{
return null;
}
var trimmedName = name.Trim();
var trimmedVersion = version.Trim();
var normalizedName = NormalizePackageName(trimmedName);
var purl = $"pkg:pypi/{normalizedName}@{trimmedVersion}";
var metadataEntries = new List<KeyValuePair<string, string?>>();
var evidenceEntries = new List<LanguageComponentEvidence>();
AddFileEvidence(context, metadataPath, "METADATA", evidenceEntries);
AddFileEvidence(context, wheelPath, "WHEEL", evidenceEntries);
AddFileEvidence(context, entryPointsPath, "entry_points.txt", evidenceEntries);
AppendMetadata(metadataEntries, "distInfoPath", PythonPathHelper.NormalizeRelative(context, distInfoPath));
AppendMetadata(metadataEntries, "name", trimmedName);
AppendMetadata(metadataEntries, "version", trimmedVersion);
AppendMetadata(metadataEntries, "summary", metadataDocument.GetFirst("Summary"));
AppendMetadata(metadataEntries, "license", metadataDocument.GetFirst("License"));
AppendMetadata(metadataEntries, "homePage", metadataDocument.GetFirst("Home-page"));
AppendMetadata(metadataEntries, "author", metadataDocument.GetFirst("Author"));
AppendMetadata(metadataEntries, "authorEmail", metadataDocument.GetFirst("Author-email"));
AppendMetadata(metadataEntries, "projectUrl", metadataDocument.GetFirst("Project-URL"));
AppendMetadata(metadataEntries, "requiresPython", metadataDocument.GetFirst("Requires-Python"));
var classifiers = metadataDocument.GetAll("Classifier");
if (classifiers.Count > 0)
{
AppendMetadata(metadataEntries, "classifiers", string.Join(';', classifiers));
}
var requiresDist = metadataDocument.GetAll("Requires-Dist");
if (requiresDist.Count > 0)
{
AppendMetadata(metadataEntries, "requiresDist", string.Join(';', requiresDist));
}
var entryPoints = await PythonEntryPointSet.LoadAsync(entryPointsPath, cancellationToken).ConfigureAwait(false);
foreach (var group in entryPoints.Groups.OrderBy(static g => g.Key, StringComparer.OrdinalIgnoreCase))
{
AppendMetadata(metadataEntries, $"entryPoints.{group.Key}", string.Join(';', group.Value.Select(static ep => $"{ep.Name}={ep.Target}")));
}
var wheelInfo = await PythonWheelInfo.LoadAsync(wheelPath, cancellationToken).ConfigureAwait(false);
if (wheelInfo is not null)
{
foreach (var pair in wheelInfo.ToMetadata())
{
AppendMetadata(metadataEntries, pair.Key, pair.Value);
}
}
var installer = await ReadSingleLineAsync(installerPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(installer))
{
AppendMetadata(metadataEntries, "installer", installer);
}
var directUrl = await PythonDirectUrlInfo.LoadAsync(directUrlPath, cancellationToken).ConfigureAwait(false);
if (directUrl is not null)
{
foreach (var pair in directUrl.ToMetadata())
{
AppendMetadata(metadataEntries, pair.Key, pair.Value);
}
if (!string.IsNullOrWhiteSpace(directUrl.Url))
{
evidenceEntries.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"direct_url.json",
PythonPathHelper.NormalizeRelative(context, directUrlPath),
directUrl.Url,
Sha256: null));
}
}
var recordEntries = await PythonRecordParser.LoadAsync(recordPath, cancellationToken).ConfigureAwait(false);
var verification = await PythonRecordVerifier.VerifyAsync(context, distInfoPath, recordEntries, cancellationToken).ConfigureAwait(false);
metadataEntries.Add(new KeyValuePair<string, string?>("record.totalEntries", verification.TotalEntries.ToString(CultureInfo.InvariantCulture)));
metadataEntries.Add(new KeyValuePair<string, string?>("record.hashedEntries", verification.HashedEntries.ToString(CultureInfo.InvariantCulture)));
metadataEntries.Add(new KeyValuePair<string, string?>("record.missingFiles", verification.MissingFiles.ToString(CultureInfo.InvariantCulture)));
metadataEntries.Add(new KeyValuePair<string, string?>("record.hashMismatches", verification.HashMismatches.ToString(CultureInfo.InvariantCulture)));
metadataEntries.Add(new KeyValuePair<string, string?>("record.ioErrors", verification.IoErrors.ToString(CultureInfo.InvariantCulture)));
if (verification.UnsupportedAlgorithms.Count > 0)
{
AppendMetadata(metadataEntries, "record.unsupportedAlgorithms", string.Join(';', verification.UnsupportedAlgorithms));
}
evidenceEntries.AddRange(verification.Evidence);
var usedByEntrypoint = verification.UsedByEntrypoint || EvaluateEntryPointUsage(context, distInfoPath, entryPoints);
return new PythonDistribution(
trimmedName,
trimmedVersion,
purl,
metadataEntries,
evidenceEntries,
usedByEntrypoint);
}
private static bool EvaluateEntryPointUsage(LanguageAnalyzerContext context, string distInfoPath, PythonEntryPointSet entryPoints)
{
if (entryPoints.Groups.Count == 0)
{
return false;
}
var parentDirectory = Directory.GetParent(distInfoPath)?.FullName;
if (string.IsNullOrWhiteSpace(parentDirectory))
{
return false;
}
foreach (var group in entryPoints.Groups.Values)
{
foreach (var entryPoint in group)
{
var candidatePaths = entryPoint.GetCandidateRelativeScriptPaths();
foreach (var relative in candidatePaths)
{
var combined = Path.GetFullPath(Path.Combine(parentDirectory, relative));
if (context.UsageHints.IsPathUsed(combined))
{
return true;
}
}
}
}
return false;
}
private static void AddFileEvidence(LanguageAnalyzerContext context, string path, string source, ICollection<LanguageComponentEvidence> evidence)
{
if (!File.Exists(path))
{
return;
}
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
source,
PythonPathHelper.NormalizeRelative(context, path),
Value: null,
Sha256: null));
}
private static void AppendMetadata(ICollection<KeyValuePair<string, string?>> metadata, string key, string? value)
{
if (string.IsNullOrWhiteSpace(key))
{
return;
}
if (string.IsNullOrWhiteSpace(value))
{
return;
}
metadata.Add(new KeyValuePair<string, string?>(key, value.Trim()));
}
private static string? ExtractNameFromDirectory(string distInfoPath)
{
var directoryName = Path.GetFileName(distInfoPath);
if (string.IsNullOrWhiteSpace(directoryName))
{
return null;
}
var suffixIndex = directoryName.IndexOf(".dist-info", StringComparison.OrdinalIgnoreCase);
if (suffixIndex <= 0)
{
return null;
}
var trimmed = directoryName[..suffixIndex];
var dashIndex = trimmed.LastIndexOf('-');
if (dashIndex <= 0)
{
return trimmed;
}
return trimmed[..dashIndex];
}
private static string? ExtractVersionFromDirectory(string distInfoPath)
{
var directoryName = Path.GetFileName(distInfoPath);
if (string.IsNullOrWhiteSpace(directoryName))
{
return null;
}
var suffixIndex = directoryName.IndexOf(".dist-info", StringComparison.OrdinalIgnoreCase);
if (suffixIndex <= 0)
{
return null;
}
var trimmed = directoryName[..suffixIndex];
var dashIndex = trimmed.LastIndexOf('-');
if (dashIndex >= 0 && dashIndex < trimmed.Length - 1)
{
return trimmed[(dashIndex + 1)..];
}
return null;
}
private static string NormalizePackageName(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return string.Empty;
}
var builder = new StringBuilder(name.Length);
foreach (var ch in name.Trim().ToLowerInvariant())
{
builder.Append(ch switch
{
'_' => '-',
'.' => '-',
' ' => '-',
_ => ch
});
}
return builder.ToString();
}
private static async Task<string?> ReadSingleLineAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return null;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true);
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
return line?.Trim();
}
}
internal sealed record PythonDistribution(
string Name,
string Version,
string Purl,
IReadOnlyCollection<KeyValuePair<string, string?>> Metadata,
IReadOnlyCollection<LanguageComponentEvidence> Evidence,
bool UsedByEntrypoint)
{
public IReadOnlyCollection<KeyValuePair<string, string?>> SortedMetadata =>
Metadata
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToArray();
public IReadOnlyCollection<LanguageComponentEvidence> SortedEvidence =>
Evidence
.OrderBy(static item => item.Locator, StringComparer.Ordinal)
.ToArray();
}
internal sealed class PythonMetadataDocument
{
private readonly Dictionary<string, List<string>> _values;
private PythonMetadataDocument(Dictionary<string, List<string>> values)
{
_values = values;
}
public static async Task<PythonMetadataDocument> LoadAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return new PythonMetadataDocument(new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase));
}
var values = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true);
string? currentKey = null;
var builder = new StringBuilder();
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
if (line.Length == 0)
{
Commit();
continue;
}
if (line.StartsWith(' ') || line.StartsWith('\t'))
{
if (currentKey is not null)
{
if (builder.Length > 0)
{
builder.Append(' ');
}
builder.Append(line.Trim());
}
continue;
}
Commit();
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
currentKey = line[..separator].Trim();
builder.Clear();
builder.Append(line[(separator + 1)..].Trim());
}
Commit();
return new PythonMetadataDocument(values);
void Commit()
{
if (string.IsNullOrWhiteSpace(currentKey))
{
return;
}
if (!values.TryGetValue(currentKey, out var list))
{
list = new List<string>();
values[currentKey] = list;
}
var value = builder.ToString().Trim();
if (value.Length > 0)
{
list.Add(value);
}
currentKey = null;
builder.Clear();
}
}
public string? GetFirst(string key)
{
if (key is null)
{
return null;
}
return _values.TryGetValue(key, out var list) && list.Count > 0
? list[0]
: null;
}
public IReadOnlyList<string> GetAll(string key)
{
if (key is null)
{
return Array.Empty<string>();
}
return _values.TryGetValue(key, out var list)
? list.AsReadOnly()
: Array.Empty<string>();
}
}
internal sealed class PythonWheelInfo
{
private readonly Dictionary<string, string> _values;
private PythonWheelInfo(Dictionary<string, string> values)
{
_values = values;
}
public static async Task<PythonWheelInfo?> LoadAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return null;
}
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true);
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
var key = line[..separator].Trim();
var value = line[(separator + 1)..].Trim();
if (key.Length == 0 || value.Length == 0)
{
continue;
}
values[key] = value;
}
return new PythonWheelInfo(values);
}
public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata()
{
var entries = new List<KeyValuePair<string, string?>>(4);
if (_values.TryGetValue("Wheel-Version", out var wheelVersion))
{
entries.Add(new KeyValuePair<string, string?>("wheel.version", wheelVersion));
}
if (_values.TryGetValue("Tag", out var tags))
{
entries.Add(new KeyValuePair<string, string?>("wheel.tags", tags));
}
if (_values.TryGetValue("Root-Is-Purelib", out var purelib))
{
entries.Add(new KeyValuePair<string, string?>("wheel.rootIsPurelib", purelib));
}
if (_values.TryGetValue("Generator", out var generator))
{
entries.Add(new KeyValuePair<string, string?>("wheel.generator", generator));
}
return entries;
}
}
internal sealed class PythonEntryPointSet
{
public IReadOnlyDictionary<string, IReadOnlyList<PythonEntryPoint>> Groups { get; }
private PythonEntryPointSet(Dictionary<string, IReadOnlyList<PythonEntryPoint>> groups)
{
Groups = groups;
}
public static async Task<PythonEntryPointSet> LoadAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return new PythonEntryPointSet(new Dictionary<string, IReadOnlyList<PythonEntryPoint>>(StringComparer.OrdinalIgnoreCase));
}
var groups = new Dictionary<string, List<PythonEntryPoint>>(StringComparer.OrdinalIgnoreCase);
string? currentGroup = null;
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true);
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
line = line.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith('[') && line.EndsWith(']'))
{
currentGroup = line[1..^1].Trim();
if (currentGroup.Length == 0)
{
currentGroup = null;
}
continue;
}
if (currentGroup is null)
{
continue;
}
var separator = line.IndexOf('=');
if (separator <= 0)
{
continue;
}
var name = line[..separator].Trim();
var target = line[(separator + 1)..].Trim();
if (name.Length == 0 || target.Length == 0)
{
continue;
}
if (!groups.TryGetValue(currentGroup, out var list))
{
list = new List<PythonEntryPoint>();
groups[currentGroup] = list;
}
list.Add(new PythonEntryPoint(name, target));
}
return new PythonEntryPointSet(groups.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyList<PythonEntryPoint>)pair.Value.AsReadOnly(),
StringComparer.OrdinalIgnoreCase));
}
}
internal sealed record PythonEntryPoint(string Name, string Target)
{
public IReadOnlyCollection<string> GetCandidateRelativeScriptPaths()
{
var list = new List<string>(3)
{
Path.Combine("bin", Name),
Path.Combine("Scripts", $"{Name}.exe"),
Path.Combine("Scripts", Name)
};
return list;
}
}
internal sealed record PythonRecordEntry(string Path, string? HashAlgorithm, string? HashValue, long? Size);
internal static class PythonRecordParser
{
public static async Task<IReadOnlyList<PythonRecordEntry>> LoadAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return Array.Empty<PythonRecordEntry>();
}
var entries = new List<PythonRecordEntry>();
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true);
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
if (line.Length == 0)
{
continue;
}
var fields = ParseCsvLine(line);
if (fields.Count < 1)
{
continue;
}
var entryPath = fields[0];
string? algorithm = null;
string? hashValue = null;
if (fields.Count > 1 && !string.IsNullOrWhiteSpace(fields[1]))
{
var hashField = fields[1].Trim();
var separator = hashField.IndexOf('=');
if (separator > 0 && separator < hashField.Length - 1)
{
algorithm = hashField[..separator];
hashValue = hashField[(separator + 1)..];
}
}
long? size = null;
if (fields.Count > 2 && long.TryParse(fields[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize))
{
size = parsedSize;
}
entries.Add(new PythonRecordEntry(entryPath, algorithm, hashValue, size));
}
return entries;
}
private static List<string> ParseCsvLine(string line)
{
var values = new List<string>();
var builder = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (inQuotes)
{
if (ch == '"')
{
var next = i + 1 < line.Length ? line[i + 1] : '\0';
if (next == '"')
{
builder.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
builder.Append(ch);
}
continue;
}
if (ch == ',')
{
values.Add(builder.ToString());
builder.Clear();
continue;
}
if (ch == '"')
{
inQuotes = true;
continue;
}
builder.Append(ch);
}
values.Add(builder.ToString());
return values;
}
}
internal sealed class PythonRecordVerificationResult
{
public PythonRecordVerificationResult(
int totalEntries,
int hashedEntries,
int missingFiles,
int hashMismatches,
int ioErrors,
bool usedByEntrypoint,
IReadOnlyCollection<string> unsupportedAlgorithms,
IReadOnlyCollection<LanguageComponentEvidence> evidence)
{
TotalEntries = totalEntries;
HashedEntries = hashedEntries;
MissingFiles = missingFiles;
HashMismatches = hashMismatches;
IoErrors = ioErrors;
UsedByEntrypoint = usedByEntrypoint;
UnsupportedAlgorithms = unsupportedAlgorithms;
Evidence = evidence;
}
public int TotalEntries { get; }
public int HashedEntries { get; }
public int MissingFiles { get; }
public int HashMismatches { get; }
public int IoErrors { get; }
public bool UsedByEntrypoint { get; }
public IReadOnlyCollection<string> UnsupportedAlgorithms { get; }
public IReadOnlyCollection<LanguageComponentEvidence> Evidence { get; }
}
internal static class PythonRecordVerifier
{
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"sha256"
};
public static async Task<PythonRecordVerificationResult> VerifyAsync(
LanguageAnalyzerContext context,
string distInfoPath,
IReadOnlyList<PythonRecordEntry> entries,
CancellationToken cancellationToken)
{
if (entries.Count == 0)
{
return new PythonRecordVerificationResult(0, 0, 0, 0, 0, usedByEntrypoint: false, Array.Empty<string>(), Array.Empty<LanguageComponentEvidence>());
}
var evidence = new List<LanguageComponentEvidence>();
var unsupported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var root = context.RootPath;
if (!root.EndsWith(Path.DirectorySeparatorChar))
{
root += Path.DirectorySeparatorChar;
}
var parent = Directory.GetParent(distInfoPath)?.FullName ?? distInfoPath;
var total = 0;
var hashed = 0;
var missing = 0;
var mismatched = 0;
var ioErrors = 0;
var usedByEntrypoint = false;
foreach (var entry in entries)
{
cancellationToken.ThrowIfCancellationRequested();
total++;
var entryPath = entry.Path.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(parent, entryPath));
if (!fullPath.StartsWith(root, StringComparison.Ordinal))
{
missing++;
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Derived,
"RECORD",
PythonPathHelper.NormalizeRelative(context, fullPath),
"outside-root",
Sha256: null));
continue;
}
if (!File.Exists(fullPath))
{
missing++;
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Derived,
"RECORD",
PythonPathHelper.NormalizeRelative(context, fullPath),
"missing",
Sha256: null));
continue;
}
if (context.UsageHints.IsPathUsed(fullPath))
{
usedByEntrypoint = true;
}
if (string.IsNullOrWhiteSpace(entry.HashAlgorithm) || string.IsNullOrWhiteSpace(entry.HashValue))
{
continue;
}
hashed++;
if (!SupportedAlgorithms.Contains(entry.HashAlgorithm))
{
unsupported.Add(entry.HashAlgorithm);
continue;
}
string? actualHash = null;
try
{
actualHash = await ComputeSha256Base64Async(fullPath, cancellationToken).ConfigureAwait(false);
}
catch (IOException)
{
ioErrors++;
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Derived,
"RECORD",
PythonPathHelper.NormalizeRelative(context, fullPath),
"io-error",
Sha256: null));
continue;
}
catch (UnauthorizedAccessException)
{
ioErrors++;
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Derived,
"RECORD",
PythonPathHelper.NormalizeRelative(context, fullPath),
"access-denied",
Sha256: null));
continue;
}
if (actualHash is null)
{
continue;
}
if (!string.Equals(actualHash, entry.HashValue, StringComparison.Ordinal))
{
mismatched++;
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Derived,
"RECORD",
PythonPathHelper.NormalizeRelative(context, fullPath),
$"sha256 mismatch expected={entry.HashValue} actual={actualHash}",
Sha256: actualHash));
}
}
return new PythonRecordVerificationResult(
total,
hashed,
missing,
mismatched,
ioErrors,
usedByEntrypoint,
unsupported.ToArray(),
evidence);
}
private static async Task<string> ComputeSha256Base64Async(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = SHA256.Create();
var buffer = ArrayPool<byte>.Shared.Rent(81920);
try
{
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
{
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
return Convert.ToBase64String(sha.Hash ?? Array.Empty<byte>());
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
internal sealed class PythonDirectUrlInfo
{
public string? Url { get; }
public bool IsEditable { get; }
public string? Subdirectory { get; }
public string? Vcs { get; }
public string? Commit { get; }
private PythonDirectUrlInfo(string? url, bool isEditable, string? subdirectory, string? vcs, string? commit)
{
Url = url;
IsEditable = isEditable;
Subdirectory = subdirectory;
Vcs = vcs;
Commit = commit;
}
public static async Task<PythonDirectUrlInfo?> LoadAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return null;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
var url = root.TryGetProperty("url", out var urlElement) ? urlElement.GetString() : null;
var isEditable = root.TryGetProperty("dir_info", out var dirInfo) && dirInfo.TryGetProperty("editable", out var editableValue) && editableValue.GetBoolean();
var subdir = root.TryGetProperty("dir_info", out dirInfo) && dirInfo.TryGetProperty("subdirectory", out var subdirElement) ? subdirElement.GetString() : null;
string? vcs = null;
string? commit = null;
if (root.TryGetProperty("vcs_info", out var vcsInfo))
{
vcs = vcsInfo.TryGetProperty("vcs", out var vcsElement) ? vcsElement.GetString() : null;
commit = vcsInfo.TryGetProperty("commit_id", out var commitElement) ? commitElement.GetString() : null;
}
return new PythonDirectUrlInfo(url, isEditable, subdir, vcs, commit);
}
public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata()
{
var entries = new List<KeyValuePair<string, string?>>();
if (IsEditable)
{
entries.Add(new KeyValuePair<string, string?>("editable", "true"));
}
if (!string.IsNullOrWhiteSpace(Url))
{
entries.Add(new KeyValuePair<string, string?>("sourceUrl", Url));
}
if (!string.IsNullOrWhiteSpace(Subdirectory))
{
entries.Add(new KeyValuePair<string, string?>("sourceSubdirectory", Subdirectory));
}
if (!string.IsNullOrWhiteSpace(Vcs))
{
entries.Add(new KeyValuePair<string, string?>("sourceVcs", Vcs));
}
if (!string.IsNullOrWhiteSpace(Commit))
{
entries.Add(new KeyValuePair<string, string?>("sourceCommit", Commit));
}
return entries;
}
}
internal static class PythonPathHelper
{
public static string NormalizeRelative(LanguageAnalyzerContext context, string path)
{
var relative = context.GetRelativePath(path);
if (string.IsNullOrEmpty(relative) || relative == ".")
{
return ".";
}
return relative;
}
}
internal static class PythonEncoding
{
public static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.Scanner.Analyzers.Lang.Python;
internal static class Placeholder
{
// Analyzer implementation will be added during Sprint LA2.
}

View File

@@ -0,0 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Python;
public sealed class PythonAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Python";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new PythonLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,72 @@
using System.Text.Json;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Python;
public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
{
private static readonly EnumerationOptions Enumeration = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
};
public string Id => "python";
public string DisplayName => "Python Analyzer";
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
return AnalyzeInternalAsync(context, writer, cancellationToken);
}
private static async ValueTask AnalyzeInternalAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
var distInfoDirectories = Directory
.EnumerateDirectories(context.RootPath, "*.dist-info", Enumeration)
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
foreach (var distInfoPath in distInfoDirectories)
{
cancellationToken.ThrowIfCancellationRequested();
PythonDistribution? distribution;
try
{
distribution = await PythonDistributionLoader.LoadAsync(context, distInfoPath, cancellationToken).ConfigureAwait(false);
}
catch (IOException)
{
continue;
}
catch (JsonException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (distribution is null)
{
continue;
}
writer.AddFromPurl(
analyzerId: "python",
purl: distribution.Purl,
name: distribution.Name,
version: distribution.Version,
type: "pypi",
metadata: distribution.SortedMetadata,
evidence: distribution.SortedEvidence,
usedByEntrypoint: distribution.UsedByEntrypoint);
}
}
}

View File

@@ -2,9 +2,9 @@
| Seq | ID | Status | Depends on | Description | Exit Criteria |
|-----|----|--------|------------|-------------|---------------|
| 1 | SCANNER-ANALYZERS-LANG-10-303A | TODO | SCANNER-ANALYZERS-LANG-10-307 | STREAM-based parser for `*.dist-info` (`METADATA`, `WHEEL`, `entry_points.txt`) with normalization + evidence capture. | Parser handles CPython 3.83.12 metadata variations; fixtures confirm canonical ordering and UTF-8 handling. |
| 2 | SCANNER-ANALYZERS-LANG-10-303B | TODO | SCANNER-ANALYZERS-LANG-10-303A | RECORD hash verifier with chunked hashing, Zip64 support, and mismatch diagnostics. | Verifier processes 5GB RECORD fixture without allocations >2MB; mismatches produce deterministic evidence records. |
| 3 | SCANNER-ANALYZERS-LANG-10-303C | TODO | SCANNER-ANALYZERS-LANG-10-303B | Editable install + pip cache detection; integrate EntryTrace hints for runtime usage flags. | Editable installs resolved to source path; usage flags propagated; regression tests cover mixed editable + wheel installs. |
| 1 | SCANNER-ANALYZERS-LANG-10-303A | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-307 | STREAM-based parser for `*.dist-info` (`METADATA`, `WHEEL`, `entry_points.txt`) with normalization + evidence capture. | Parser handles CPython 3.83.12 metadata variations; fixtures confirm canonical ordering and UTF-8 handling. |
| 2 | SCANNER-ANALYZERS-LANG-10-303B | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-303A | RECORD hash verifier with chunked hashing, Zip64 support, and mismatch diagnostics. | Verifier processes 5GB RECORD fixture without allocations >2MB; mismatches produce deterministic evidence records. |
| 3 | SCANNER-ANALYZERS-LANG-10-303C | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-303B | Editable install + pip cache detection; integrate EntryTrace hints for runtime usage flags. | Editable installs resolved to source path; usage flags propagated; regression tests cover mixed editable + wheel installs. |
| 4 | SCANNER-ANALYZERS-LANG-10-307P | TODO | SCANNER-ANALYZERS-LANG-10-303C | Shared helper integration (license metadata, quiet provenance, component merging). | Shared helpers reused; analyzer-specific metadata minimal; deterministic merge tests pass. |
| 5 | SCANNER-ANALYZERS-LANG-10-308P | TODO | SCANNER-ANALYZERS-LANG-10-307P | Golden fixtures + determinism harness for Python analyzer; add benchmark and hash throughput reporting. | Fixtures under `Fixtures/lang/python/`; determinism CI guard; benchmark CSV added with threshold alerts. |
| 6 | SCANNER-ANALYZERS-LANG-10-309P | TODO | SCANNER-ANALYZERS-LANG-10-308P | Package plug-in (manifest, DI registration) and document Offline Kit bundling of Python stdlib metadata if needed. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. |

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.python",
"displayName": "StellaOps Python Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Python.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Python.PythonAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"python",
"pypi"
],
"metadata": {
"org.stellaops.analyzer.language": "python",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}

View File

@@ -0,0 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Rust;
public sealed class RustAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Rust";
public bool IsAvailable(IServiceProvider services) => false;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new RustLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Analyzers.Lang.Rust;
public sealed class RustLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "rust";
public string DisplayName => "Rust Analyzer (preview)";
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
=> ValueTask.FromException(new NotImplementedException("Rust analyzer implementation pending Sprint LA5."));
}

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.rust",
"displayName": "StellaOps Rust Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Rust.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Rust.RustAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"rust",
"cargo"
],
"metadata": {
"org.stellaops.analyzer.language": "rust",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}

View File

@@ -0,0 +1,28 @@
using System.IO;
using StellaOps.Scanner.Analyzers.Lang.DotNet;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Tests.DotNet;
public sealed class DotNetLanguageAnalyzerTests
{
[Fact]
public async Task SimpleFixtureProducesDeterministicOutputAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "simple");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new DotNetLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
}

Some files were not shown because too many files have changed in this diff Show More