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/ics-cisa/*.sha256 | ||||||
| seed-data/cert-bund/**/*.json | seed-data/cert-bund/**/*.json | ||||||
| seed-data/cert-bund/**/*.sha256 | 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 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 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 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 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 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. | - 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 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 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 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 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. | - 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: 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 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 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 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 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. | - 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 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 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 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 – 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 – 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. | - 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 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 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 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 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 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 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 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 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 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 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. | - 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 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 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 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. | - 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 | ### 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 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 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. | - 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` |     - 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. |       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 |          • Prereqs: WEB1.TRIVY-SETTINGS | ||||||
|          • Current: DONE (2025-10-21) – ChromeHeadless launcher + README updates merged; awaiting dependency hardening follow-up (WEB1.DEPS-13-001). |          • Current: DONE (2025-10-21) – ChromeHeadless launcher + README updates merged; dependency hardening completed via WEB1.DEPS-13-001. | ||||||
|       3. [TODO] WEB1.DEPS-13-001 — Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs). |       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 |          • 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 | - **Sprint 1** · Developer Tooling | ||||||
|   - Team: DevEx/CLI |   - Team: DevEx/CLI | ||||||
|     - Path: `src/StellaOps.Cli/TASKS.md` |     - 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 | - **Sprint 10** · Backlog | ||||||
|   - Team: TBD |   - Team: TBD | ||||||
|     - Path: `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md` |     - 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) |          • 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 | - **Sprint 10** · Scanner Analyzers & SBOM | ||||||
|   - Team: Diff Guild |   - Team: Diff Guild | ||||||
|     - Path: `src/StellaOps.Scanner.Diff/TASKS.md` |     - 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: — |          • Prereqs: — | ||||||
|          • Current: TODO |          • Current: TODO | ||||||
|     - Path: `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md` |     - 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: — |          • Prereqs: — | ||||||
|          • Current: TODO |          • Current: DONE — Java analyzer shipped with deterministic fixtures. | ||||||
|       2. [TODO] SCANNER-ANALYZERS-LANG-10-307 — Shared language evidence helpers + usage flag propagation. |       2. [DONE 2025-10-19] SCANNER-ANALYZERS-LANG-10-307 — Shared language evidence helpers + usage flag propagation. | ||||||
|          • Prereqs: — |          • Prereqs: — | ||||||
|          • Current: TODO |          • Current: DONE — Shared helpers live under Lang.Core and are consumed by Java/Node analyzers. | ||||||
|       3. [TODO] SCANNER-ANALYZERS-LANG-10-308 — Determinism + fixture harness for language analyzers. |       3. [DONE 2025-10-19] SCANNER-ANALYZERS-LANG-10-308 — Determinism + fixture harness for language analyzers. | ||||||
|          • Prereqs: — |          • Prereqs: — | ||||||
|          • Current: TODO |          • Current: DONE — Determinism harness + fixtures checked in; CI guard active. | ||||||
| - **Sprint 11** · Signing Chain Bring-up | - **Sprint 11** · Signing Chain Bring-up | ||||||
|   - Team: Attestor Guild |   - Team: Attestor Guild | ||||||
|     - Path: `src/StellaOps.Attestor/TASKS.md` |     - 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. |       3. [TODO] DEVOPS-LAUNCH-18-001 - Production launch cutover rehearsal and runbook publication. | ||||||
|          • Prereqs: DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 |          • Prereqs: DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 | ||||||
|          • Current: TODO |          • 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 | ## Wave 1 — 45 task(s) ready after Wave 0 | ||||||
| - **Sprint 6** · Excititor Ingest & Formats | - **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 | - **Sprint 10** · Backlog | ||||||
|   - Team: TBD |   - Team: TBD | ||||||
|     - Path: `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md` |     - 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) |          • 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` |     - 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) |          • 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` |     - 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) |          • 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` |     - 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) |          • 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` |     - 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. |       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) |          • 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 |          • Current: TODO | ||||||
|   - Team: Language Analyzer Guild |   - Team: Language Analyzer Guild | ||||||
|     - Path: `src/StellaOps.Scanner.Analyzers.Lang/TASKS.md` |     - 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) |          • 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}`. |       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) |          • Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0) | ||||||
|          • Current: TODO |          • 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) |          • Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0) | ||||||
|          • Current: DOING (2025-10-19) |          • Current: DONE — Workspace/symlink coverage validated via determinism fixtures; metrics + lifecycle script evidence landed. | ||||||
|       4. [TODO] SCANNER-ANALYZERS-LANG-10-304 — Go analyzer leveraging buildinfo for `pkg:golang` components. |       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) |          • Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0) | ||||||
|          • Current: TODO |          • 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) |          • Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0) | ||||||
|          • Current: TODO |          • Current: DOING — Implementing initial deps/runtimeconfig parsing for RID-aware components. | ||||||
|       6. [TODO] SCANNER-ANALYZERS-LANG-10-303 — Python analyzer consuming `*.dist-info` metadata and RECORD hashes. |       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) |          • 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 | - **Sprint 11** · UI Integration | ||||||
|   - Team: UI Guild |   - Team: UI Guild | ||||||
|     - Path: `src/StellaOps.UI/TASKS.md` |     - 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. |          • 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 |   - Team: UX Specialist, Angular Eng, DevEx | ||||||
|     - Path: `src/StellaOps.Web/TASKS.md` |     - 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) |          • 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. |          • Current: TODO – Capture deterministic lockfile flow, cache Puppeteer downloads, validate `npm test` from clean checkout offline, and update README. | ||||||
|   - Team: UI Guild |   - 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) |          • Prereqs: SCANNER-ANALYZERS-LANG-10-305A (Wave 1) | ||||||
|          • Current: TODO |          • Current: TODO | ||||||
|     - Path: `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md` |     - 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) |          • 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` |     - 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) |          • 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` |     - 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) |          • 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` |     - 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. |       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) |          • 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) |          • Prereqs: SCANNER-ANALYZERS-LANG-10-304B (Wave 2) | ||||||
|          • Current: TODO |          • Current: TODO | ||||||
|     - Path: `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md` |     - 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) |          • 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` |     - 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) |          • 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` |     - 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. |       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) |          • 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 | - **Sprint 13** · UX & CLI Experience | ||||||
|   - Team: DevEx/CLI |   - Team: DevEx/CLI | ||||||
|     - Path: `src/StellaOps.Cli/TASKS.md` |     - 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) |          • 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). |          • Current: TODO – Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload). | ||||||
| - **Sprint 15** · Notify Foundations | - **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-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.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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 | 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 | 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 | 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 | 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-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-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-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. | | | 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 | 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.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-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.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 | 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.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 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/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. | | | 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 | 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 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/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 | | | **Provenance** | Cosign signature, SPDX 2.3 SBOM, in‑toto SLSA attestation | | ||||||
| | **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. | | | **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. | | ||||||
| | **Delta patches** | Daily diff bundles keep size \< 350 MB | | | **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. | **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 }}**.   | *Scanner core:* C# 12 on **.NET {{ dotnet }}**.   | ||||||
| *Imports are idempotent and atomic — no service downtime.* | *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. | **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. | **OS targets**: linux‑x64/arm64, windows‑x64/arm64, macOS‑x64/arm64. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,3 +3,4 @@ | |||||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | | 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-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.CommandLine; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
| using StellaOps.Cli.Configuration; | using StellaOps.Cli.Configuration; | ||||||
|  | using StellaOps.Cli.Plugins; | ||||||
|  |  | ||||||
| namespace StellaOps.Cli.Commands; | namespace StellaOps.Cli.Commands; | ||||||
|  |  | ||||||
| internal static class CommandFactory | 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" }) |         var verboseOption = new Option<bool>("--verbose", new[] { "-v" }) | ||||||
|         { |         { | ||||||
|             Description = "Enable verbose logging output." |             Description = "Enable verbose logging output." | ||||||
| @@ -24,12 +32,13 @@ internal static class CommandFactory | |||||||
|         root.Add(BuildScannerCommand(services, verboseOption, cancellationToken)); |         root.Add(BuildScannerCommand(services, verboseOption, cancellationToken)); | ||||||
|         root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken)); |         root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken)); | ||||||
|         root.Add(BuildDatabaseCommand(services, 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(BuildAuthCommand(services, options, verboseOption, cancellationToken)); | ||||||
|         root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken)); |  | ||||||
|         root.Add(BuildConfigCommand(options)); |         root.Add(BuildConfigCommand(options)); | ||||||
|  |  | ||||||
|  |         var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>(); | ||||||
|  |         var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger); | ||||||
|  |         pluginLoader.RegisterModules(root, verboseOption, cancellationToken); | ||||||
|  |  | ||||||
|         return root; |         return root; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -227,300 +236,6 @@ internal static class CommandFactory | |||||||
|         return db; |         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) |     private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||||
|     { |     { | ||||||
|         var auth = new Command("auth", "Manage authentication with StellaOps Authority."); |         var auth = new Command("auth", "Manage authentication with StellaOps Authority."); | ||||||
| @@ -607,97 +322,6 @@ internal static class CommandFactory | |||||||
|         return auth; |         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) |     private static Command BuildConfigCommand(StellaOpsCliOptions options) | ||||||
|     { |     { | ||||||
|         var config = new Command("config", "Inspect CLI configuration state."); |         var config = new Command("config", "Inspect CLI configuration state."); | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using System.IO; | using System.IO; | ||||||
|  | using System.Linq; | ||||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; | ||||||
| using StellaOps.Configuration; | using StellaOps.Configuration; | ||||||
| using StellaOps.Auth.Abstractions; | using StellaOps.Auth.Abstractions; | ||||||
| @@ -234,6 +235,93 @@ public static class CliBootstrapper | |||||||
|                     "Offline:MirrorUrl"); |                     "Offline:MirrorUrl"); | ||||||
|  |  | ||||||
|                 offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim(); |                 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; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using StellaOps.Auth.Abstractions; | using StellaOps.Auth.Abstractions; | ||||||
|  | using System.IO; | ||||||
|  |  | ||||||
| namespace StellaOps.Cli.Configuration; | namespace StellaOps.Cli.Configuration; | ||||||
|  |  | ||||||
| @@ -25,6 +26,8 @@ public sealed class StellaOpsCliOptions | |||||||
|     public StellaOpsCliAuthorityOptions Authority { get; set; } = new(); |     public StellaOpsCliAuthorityOptions Authority { get; set; } = new(); | ||||||
|  |  | ||||||
|     public StellaOpsCliOfflineOptions Offline { get; set; } = new(); |     public StellaOpsCliOfflineOptions Offline { get; set; } = new(); | ||||||
|  |  | ||||||
|  |     public StellaOpsCliPluginOptions Plugins { get; set; } = new(); | ||||||
| } | } | ||||||
|  |  | ||||||
| public sealed class StellaOpsCliAuthorityOptions | public sealed class StellaOpsCliAuthorityOptions | ||||||
| @@ -63,3 +66,16 @@ public sealed class StellaOpsCliOfflineOptions | |||||||
|  |  | ||||||
|     public string? MirrorUrl { get; set; } |     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(); |             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 commandConfiguration = new CommandLineConfiguration(rootCommand); | ||||||
|         var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false); |         var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
| using System.Runtime.CompilerServices; | using System.Runtime.CompilerServices; | ||||||
|  |  | ||||||
| [assembly: InternalsVisibleTo("StellaOps.Cli.Tests")] | [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.CommandLine" Version="8.0.0" /> | ||||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" 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.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.Http" Version="8.0.0" /> | ||||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" /> |     <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" /> | ||||||
|     <PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" /> |     <PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" /> | ||||||
| @@ -39,6 +40,7 @@ | |||||||
|     <ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> |     <ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||||
|     <ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.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.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> | ||||||
|  |     <ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
| </Project> | </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.| | |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-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-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-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.| | |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 | | | 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. | | | 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. | | | 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. | | | 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 | | | 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. | | | 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 | 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 %. | | | 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. | | | 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. | | | 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. | | | 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.Harness; | ||||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | 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 | 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.Harness; | ||||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | 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 | 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. | | | 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. | | | 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. | | | 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. | | | 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 | 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. | | | 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 | 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. | | | 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; | ||||||
| global using System.Collections.Generic; | global using System.Collections.Generic; | ||||||
| global using System.IO; | global using System.IO; | ||||||
|  | global using System.Linq; | ||||||
| global using System.Threading; | global using System.Threading; | ||||||
| global using System.Threading.Tasks; | 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 | | | 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. | | | 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 | 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. | | | 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 | 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. | | | 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. | | | 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. | | | 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. | | | 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