Add scripts for resolving and verifying Chromium binary paths
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,3 +25,5 @@ seed-data/ics-cisa/*.xlsx
|
||||
seed-data/ics-cisa/*.sha256
|
||||
seed-data/cert-bund/**/*.json
|
||||
seed-data/cert-bund/**/*.sha256
|
||||
|
||||
out/offline-kit/web/**/*
|
||||
101
EXECPLAN.md
101
EXECPLAN.md
@@ -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
|
||||
|
||||
19
SPRINTS.md
19
SPRINTS.md
@@ -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. |
|
||||
|
||||
@@ -17,9 +17,12 @@ completely isolated network:
|
||||
| **Provenance** | Cosign signature, SPDX 2.3 SBOM, in‑toto SLSA attestation |
|
||||
| **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. |
|
||||
| **Delta patches** | Daily diff bundles keep size \< 350 MB |
|
||||
| **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 air‑gapped 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.*
|
||||
|
||||
|
||||
@@ -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**: linux‑x64/arm64, windows‑x64/arm64, macOS‑x64/arm64.
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
| 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. |
|
||||
|
||||
21
plugins/cli/StellaOps.Cli.Plugins.NonCore/manifest.json
Normal file
21
plugins/cli/StellaOps.Cli.Plugins.NonCore/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
416
src/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs
Normal file
416
src/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,22 @@ using System;
|
||||
using System.CommandLine;
|
||||
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)
|
||||
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."
|
||||
@@ -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.");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
278
src/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
Normal file
278
src/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
39
src/StellaOps.Cli/Plugins/CliPluginManifest.cs
Normal file
39
src/StellaOps.Cli/Plugins/CliPluginManifest.cs
Normal 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;
|
||||
}
|
||||
150
src/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
Normal file
150
src/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/StellaOps.Cli/Plugins/ICliCommandModule.cs
Normal file
20
src/StellaOps.Cli/Plugins/ICliCommandModule.cs
Normal 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);
|
||||
}
|
||||
41
src/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
Normal file
41
src/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cli.Plugins.NonCore")]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.|
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
|
||||
|
||||
internal static class Placeholder
|
||||
{
|
||||
// Analyzer implementation will be added during Sprint LA4.
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
23
src/StellaOps.Scanner.Analyzers.Lang.DotNet/manifest.json
Normal file
23
src/StellaOps.Scanner.Analyzers.Lang.DotNet/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
17
src/StellaOps.Scanner.Analyzers.Lang.Go/GoAnalyzerPlugin.cs
Normal file
17
src/StellaOps.Scanner.Analyzers.Lang.Go/GoAnalyzerPlugin.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
292
src/StellaOps.Scanner.Analyzers.Lang.Go/GoLanguageAnalyzer.cs
Normal file
292
src/StellaOps.Scanner.Analyzers.Lang.Go/GoLanguageAnalyzer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
67
src/StellaOps.Scanner.Analyzers.Lang.Go/Internal/GoModule.cs
Normal file
67
src/StellaOps.Scanner.Analyzers.Lang.Go/Internal/GoModule.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Go;
|
||||
|
||||
internal static class Placeholder
|
||||
{
|
||||
// Analyzer implementation will be added during Sprint LA3.
|
||||
}
|
||||
@@ -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.18–1.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.18–1.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. |
|
||||
|
||||
23
src/StellaOps.Scanner.Analyzers.Lang.Go/manifest.json
Normal file
23
src/StellaOps.Scanner.Analyzers.Lang.Go/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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;
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests;
|
||||
|
||||
public sealed class JavaLanguageAnalyzerTests
|
||||
{
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@ 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;
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Tests;
|
||||
|
||||
public sealed class NodeLanguageAnalyzerTests
|
||||
{
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Node;
|
||||
|
||||
internal static class Placeholder
|
||||
{
|
||||
// Analyzer implementation will be added during Sprint LA1.
|
||||
}
|
||||
@@ -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 >100 k modules in <1.5 s (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. |
|
||||
|
||||
22
src/StellaOps.Scanner.Analyzers.Lang.Node/manifest.json
Normal file
22
src/StellaOps.Scanner.Analyzers.Lang.Node/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -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
|
||||
@@ -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,,
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pip 24.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
simple-tool = simple.core:main
|
||||
@@ -0,0 +1,4 @@
|
||||
__all__ = ["main"]
|
||||
__version__ = "1.0.0"
|
||||
|
||||
from .core import main # noqa: F401
|
||||
@@ -0,0 +1,4 @@
|
||||
from .core import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
|
||||
def main() -> None:
|
||||
print("simple core", sys.argv)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,6 +1,7 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python;
|
||||
|
||||
internal static class Placeholder
|
||||
{
|
||||
// Analyzer implementation will be added during Sprint LA2.
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.8–3.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 5 GB RECORD fixture without allocations >2 MB; 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.8–3.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 5 GB RECORD fixture without allocations >2 MB; 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. |
|
||||
|
||||
23
src/StellaOps.Scanner.Analyzers.Lang.Python/manifest.json
Normal file
23
src/StellaOps.Scanner.Analyzers.Lang.Python/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
23
src/StellaOps.Scanner.Analyzers.Lang.Rust/manifest.json
Normal file
23
src/StellaOps.Scanner.Analyzers.Lang.Rust/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user