feat(rust): Implement RustCargoLockParser and RustFingerprintScanner
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Added RustCargoLockParser to parse Cargo.lock files and extract package information. - Introduced RustFingerprintScanner to scan for Rust fingerprint records in JSON files. - Created test fixtures for Rust language analysis, including Cargo.lock and fingerprint JSON files. - Developed tests for RustLanguageAnalyzer to ensure deterministic output based on provided fixtures. - Added expected output files for both simple and signed Rust applications.
This commit is contained in:
		
							
								
								
									
										12
									
								
								EXECPLAN.md
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								EXECPLAN.md
									
									
									
									
									
								
							| @@ -94,7 +94,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster | |||||||
| - 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 (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 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 (DONE 2025-10-22), 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,7 +106,7 @@ 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 (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 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 (DONE 2025-10-22), 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 | ||||||
| @@ -721,9 +721,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.DotNet/TASKS.md` |     - Path: `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md` | ||||||
|       1. [TODO] SCANNER-ANALYZERS-LANG-10-305B — Extract assembly metadata (strong name, file/product info) and optional Authenticode details when offline cert bundle provided. |       1. [DONE 2025-10-22] SCANNER-ANALYZERS-LANG-10-305B — Extract assembly metadata (strong name, file/product info) and optional Authenticode details when offline cert bundle provided. | ||||||
|          • Prereqs: SCANNER-ANALYZERS-LANG-10-305A (Wave 1) |          • Prereqs: SCANNER-ANALYZERS-LANG-10-305A (Wave 1) | ||||||
|          • Current: TODO |          • Current: DONE — Assembly metadata now emits strong-name, file/product info, and optional Authenticode signals with deterministic fixtures/tests. | ||||||
|     - Path: `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md` |     - Path: `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md` | ||||||
|       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. |       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) | ||||||
| @@ -852,8 +852,8 @@ 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-305C — Handle self-contained apps and native assets; merge with EntryTrace usage hints. |       1. [DONE 2025-10-22] SCANNER-ANALYZERS-LANG-10-305C — Handle self-contained apps and native assets; merge with EntryTrace usage hints. | ||||||
|          • Prereqs: SCANNER-ANALYZERS-LANG-10-305B (Wave 2) |          • 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-304C — Fallback heuristics for stripped binaries with deterministic `bin:{sha256}` labeling and quiet provenance. |       1. [TODO] SCANNER-ANALYZERS-LANG-10-304C — Fallback heuristics for stripped binaries with deterministic `bin:{sha256}` labeling and quiet provenance. | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | |||||||
|  |  | ||||||
| | Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description | | | Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description | | ||||||
| | --- | --- | --- | --- | --- | --- | --- | | | --- | --- | --- | --- | --- | --- | --- | | ||||||
|  | | Sprint 7 | Contextual Truth Foundations | docs/TASKS.md | DONE (2025-10-22) | Docs Guild, Concelier WebService | DOCS-CONCELIER-07-201 | Final editorial review and publish pass for Concelier authority toggle documentation (Quickstart + operator guide). | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-101 | Implement layer cache store keyed by layer digest with metadata retention per architecture §3.3. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-101 | Implement layer cache store keyed by layer digest with metadata retention per architecture §3.3. | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-102 | Build file CAS with dedupe, TTL enforcement, and offline import/export hooks. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-102 | Build file CAS with dedupe, TTL enforcement, and offline import/export hooks. | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-103 | Expose cache metrics/logging and configuration toggles for warm/cold thresholds. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-103 | Expose cache metrics/logging and configuration toggles for warm/cold thresholds. | | ||||||
| @@ -16,9 +17,9 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | |||||||
| | 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 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-302 | Node analyzer handling workspaces/symlinks emitting `pkg:npm`. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-302 | Node analyzer handling workspaces/symlinks emitting `pkg:npm`. | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-303 | Python analyzer reading `*.dist-info`, RECORD hashes, entry points. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-303 | Python analyzer reading `*.dist-info`, RECORD hashes, entry points. | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-304 | Go analyzer leveraging buildinfo for `pkg:golang` components. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-304 | Go analyzer leveraging buildinfo for `pkg:golang` components. | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-305 | .NET analyzer parsing `*.deps.json`, assembly metadata, RID variants. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (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 | DONE (2025-10-22) | 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 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Shared language evidence helpers + usage flag propagation. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Shared language evidence helpers + usage flag propagation. | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-308 | Determinism + fixture harness for language analyzers. | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-308 | Determinism + fixture harness for language analyzers. | | ||||||
| | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-309 | Package language analyzers as restart-time plug-ins (manifest + host registration). | | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.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). | | ||||||
| @@ -71,6 +72,8 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | |||||||
| | 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.Cli/TASKS.md | DONE (2025-10-22) | DevEx/CLI | CLI-PLUGIN-13-007 | Package non-core CLI verbs as restart-time plug-ins (manifest + loader tests). | | ||||||
| | Sprint 13 | UX & CLI Experience | src/StellaOps.Web/TASKS.md | DONE (2025-10-21) | UX Specialist, Angular Eng, DevEx | WEB1.DEPS-13-001 | Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs). | | | Sprint 13 | 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 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-NUGET-13-002 | Ensure all solutions/projects prioritize `local-nuget` before public feeds and add restore-order validation. | | ||||||
|  | | Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-003 | Upgrade `Microsoft.*` dependencies pinned to 8.* to their latest .NET 10 (or 9.x) releases and refresh guidance. | | ||||||
| | 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. | | ||||||
| | Sprint 14 | Release & Offline Ops | ops/deployment/TASKS.md | TODO | Deployment Guild | DEVOPS-OPS-14-003 | Deployment/update/rollback automation and channel management documentation. | | | Sprint 14 | Release & Offline Ops | ops/deployment/TASKS.md | TODO | Deployment Guild | DEVOPS-OPS-14-003 | Deployment/update/rollback automation and channel management documentation. | | ||||||
|   | |||||||
| @@ -59,6 +59,14 @@ runtime wiring, CLI usage) and leaves connector/internal customization for later | |||||||
|   > `authority.allowAnonymousFallback: true` only during the staged rollout and |   > `authority.allowAnonymousFallback: true` only during the staged rollout and | ||||||
|   > disable it before **2025-12-31 UTC** so tokens become mandatory. |   > disable it before **2025-12-31 UTC** so tokens become mandatory. | ||||||
|  |  | ||||||
|  | Rollout checkpoints for the two Authority toggles: | ||||||
|  |  | ||||||
|  | | Phase | `authority.enabled` | `authority.allowAnonymousFallback` | Goal | Observability focus | | ||||||
|  | | ----- | ------------------- | ---------------------------------- | ---- | ------------------- | | ||||||
|  | | **Validation (staging)** | `true` | `true` | Verify token issuance, CLI scopes, and audit log noise without breaking cron jobs. | Watch `Concelier.Authorization.Audit` for `bypass=True` events and scope gaps; confirm CLI `auth status` succeeds. | | ||||||
|  | | **Cutover rehearsal** | `true` | `false` | Exercise production-style enforcement before the deadline; ensure only approved maintenance ranges remain in `bypassNetworks`. | Expect some HTTP 401s; verify `web.jobs.triggered` metrics flatten for unauthenticated calls and audit logs highlight missing tokens. | | ||||||
|  | | **Enforced (steady state)** | `true` | `false` | Production baseline after the 2025-12-31 UTC cutoff. | Alert on new `bypass=True` entries and on repeated 401 bursts; correlate with Authority availability dashboards. | | ||||||
|  |  | ||||||
| ### Authority companion configuration (preview) | ### Authority companion configuration (preview) | ||||||
|  |  | ||||||
| 1. Copy the Authority sample configuration: | 1. Copy the Authority sample configuration: | ||||||
| @@ -287,3 +295,7 @@ a problem document. | |||||||
|   so that automation can obtain tokens with the same client credentials. Concelier |   so that automation can obtain tokens with the same client credentials. Concelier | ||||||
|   logs every job request with the client ID, subject (if present), scopes, and |   logs every job request with the client ID, subject (if present), scopes, and | ||||||
|   a `bypass` flag so operators can audit cron traffic. |   a `bypass` flag so operators can audit cron traffic. | ||||||
|  | - **Rollout checklist.** | ||||||
|  |   1. Stage the integration with fallback enabled (`allowAnonymousFallback=true`) and confirm CLI/token issuance using `stella auth status`. | ||||||
|  |   2. Follow the rehearsal pattern (`allowAnonymousFallback=false`) while monitoring `Concelier.Authorization.Audit` and `web.jobs.triggered`/`web.jobs.trigger.failed` metrics. | ||||||
|  |   3. Lock in enforcement, review the audit runbook (`docs/ops/concelier-authority-audit-runbook.md`), and document the bypass CIDR approvals in your change log. | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ | |||||||
| | DOCS-EVENTS-09-004 | DONE (2025-10-19) | Docs Guild, Scanner WebService | SCANNER-EVENTS-15-201 | Refresh scanner event docs to mirror DSSE-backed report fields, document `scanner.scan.completed`, and capture canonical sample validation. | Schemas updated for new payload shape; README references DSSE reuse and validation test; samples align with emitted events. | | | DOCS-EVENTS-09-004 | DONE (2025-10-19) | Docs Guild, Scanner WebService | SCANNER-EVENTS-15-201 | Refresh scanner event docs to mirror DSSE-backed report fields, document `scanner.scan.completed`, and capture canonical sample validation. | Schemas updated for new payload shape; README references DSSE reuse and validation test; samples align with emitted events. | | ||||||
| | PLATFORM-EVENTS-09-401 | DONE (2025-10-21) | Platform Events Guild | DOCS-EVENTS-09-003 | Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. | Notify models tests now run schema validation against `docs/events/*.json`, event schemas allow optional `attributes`, and docs capture the new validation workflow. | | | PLATFORM-EVENTS-09-401 | DONE (2025-10-21) | Platform Events Guild | DOCS-EVENTS-09-003 | Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. | Notify models tests now run schema validation against `docs/events/*.json`, event schemas allow optional `attributes`, and docs capture the new validation workflow. | | ||||||
| | RUNTIME-GUILD-09-402 | DONE (2025-10-19) | Runtime Guild | SCANNER-POLICY-09-107 | Confirm Scanner WebService surfaces `quietedFindingCount` and progress hints to runtime consumers; document readiness checklist. | Runtime verification run captures enriched payload; checklist/doc updates merged; stakeholders acknowledge availability. | | | RUNTIME-GUILD-09-402 | DONE (2025-10-19) | Runtime Guild | SCANNER-POLICY-09-107 | Confirm Scanner WebService surfaces `quietedFindingCount` and progress hints to runtime consumers; document readiness checklist. | Runtime verification run captures enriched payload; checklist/doc updates merged; stakeholders acknowledge availability. | | ||||||
| | DOCS-CONCELIER-07-201 | TODO | Docs Guild, Concelier WebService | FEEDWEB-DOCS-01-001 | Final editorial review and publish pass for Concelier authority toggle documentation (Quickstart + operator guide). | Review feedback resolved, publish PR merged, release notes updated with documentation pointer. | | | DOCS-CONCELIER-07-201 | DONE (2025-10-22) | Docs Guild, Concelier WebService | FEEDWEB-DOCS-01-001 | Final editorial review and publish pass for Concelier authority toggle documentation (Quickstart + operator guide). | Review feedback resolved, publish PR merged, release notes updated with documentation pointer. | | ||||||
| | DOCS-RUNTIME-17-004 | TODO | Docs Guild, Runtime Guild | SCANNER-EMIT-17-701, ZASTAVA-OBS-17-005, DEVOPS-REL-17-002 | Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval. | Architecture + operator docs updated with build-id sections, examples show `readelf` output + debuginfod usage, references linked from Offline Kit/Release guides. | | | DOCS-RUNTIME-17-004 | TODO | Docs Guild, Runtime Guild | SCANNER-EMIT-17-701, ZASTAVA-OBS-17-005, DEVOPS-REL-17-002 | Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval. | Architecture + operator docs updated with build-id sections, examples show `readelf` output + debuginfod usage, references linked from Offline Kit/Release guides. | | ||||||
|  |  | ||||||
| > Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`. | > Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`. | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| # Concelier Authority Audit Runbook | # Concelier Authority Audit Runbook | ||||||
|  |  | ||||||
| _Last updated: 2025-10-12_ | _Last updated: 2025-10-22_ | ||||||
|  |  | ||||||
| This runbook helps operators verify and monitor the StellaOps Concelier ⇆ Authority integration. It focuses on the `/jobs*` surface, which now requires StellaOps Authority tokens, and the corresponding audit/metric signals that expose authentication and bypass activity. | This runbook helps operators verify and monitor the StellaOps Concelier ⇆ Authority integration. It focuses on the `/jobs*` surface, which now requires StellaOps Authority tokens, and the corresponding audit/metric signals that expose authentication and bypass activity. | ||||||
|  |  | ||||||
| @@ -9,6 +9,7 @@ This runbook helps operators verify and monitor the StellaOps Concelier ⇆ Auth | |||||||
| - Authority integration is enabled in `concelier.yaml` (or via `CONCELIER_AUTHORITY__*` environment variables) with a valid `clientId`, secret, audience, and required scopes. | - Authority integration is enabled in `concelier.yaml` (or via `CONCELIER_AUTHORITY__*` environment variables) with a valid `clientId`, secret, audience, and required scopes. | ||||||
| - OTLP metrics/log exporters are configured (`concelier.telemetry.*`) or container stdout is shipped to your SIEM. | - OTLP metrics/log exporters are configured (`concelier.telemetry.*`) or container stdout is shipped to your SIEM. | ||||||
| - Operators have access to the Concelier job trigger endpoints via CLI or REST for smoke tests. | - Operators have access to the Concelier job trigger endpoints via CLI or REST for smoke tests. | ||||||
|  | - The rollout table in `docs/10_CONCELIER_CLI_QUICKSTART.md` has been reviewed so stakeholders align on the staged → enforced toggle timeline. | ||||||
|  |  | ||||||
| ### Configuration snippet | ### Configuration snippet | ||||||
|  |  | ||||||
| @@ -113,6 +114,7 @@ Correlate audit logs with the following global meter exported via `Concelier.Sou | |||||||
| ## 4. Rollout & Verification Procedure | ## 4. Rollout & Verification Procedure | ||||||
|  |  | ||||||
| 1. **Pre-checks** | 1. **Pre-checks** | ||||||
|  |    - Align with the rollout phases documented in `docs/10_CONCELIER_CLI_QUICKSTART.md` (validation → rehearsal → enforced) and record the target dates in your change request. | ||||||
|    - Confirm `allowAnonymousFallback` is `false` in production; keep `true` only during staged validation. |    - Confirm `allowAnonymousFallback` is `false` in production; keep `true` only during staged validation. | ||||||
|    - Validate Authority issuer metadata is reachable from Concelier (`curl https://authority.internal/.well-known/openid-configuration` from the host). |    - Validate Authority issuer metadata is reachable from Concelier (`curl https://authority.internal/.well-known/openid-configuration` from the host). | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								docs/updates/2025-10-22-docs-guild.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								docs/updates/2025-10-22-docs-guild.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | # Docs Guild Update — 2025-10-22 | ||||||
|  |  | ||||||
|  | **Subject:** Concelier Authority toggle rollout polish   | ||||||
|  | **Audience:** Docs Guild, Concelier WebService Guild, Authority Core | ||||||
|  |  | ||||||
|  | - Added a rollout phase table to `docs/10_CONCELIER_CLI_QUICKSTART.md`, clarifying how `authority.enabled` and `authority.allowAnonymousFallback` move from validation to enforced mode and highlighting the audit/metric signals to watch at each step. | ||||||
|  | - Extended the Authority integration checklist in the same quickstart so operators tie CLI smoke tests to audit counters before flipping enforcement. | ||||||
|  | - Refreshed `docs/ops/concelier-authority-audit-runbook.md` with the latest date stamp, prerequisites, and pre-check guidance that reference the quickstart timeline; keeps change-request templates aligned. | ||||||
|  |  | ||||||
|  | Next steps: | ||||||
|  | - Concelier WebService owners to link this update in the next deployment bulletin once FEEDWEB-DOCS-01-001 clears review. | ||||||
|  | - Docs Guild to verify the Offline Kit doc bundle picks up the quickstart/runbook changes after the nightly build. | ||||||
							
								
								
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Http.Polly.8.0.5.nupkg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Http.Polly.8.0.5.nupkg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Options.8.0.0.nupkg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Options.8.0.0.nupkg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Options.8.0.1.nupkg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Options.8.0.1.nupkg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Options.8.0.2.nupkg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								local-nuget/Microsoft.Extensions.Options.8.0.2.nupkg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -15,5 +15,7 @@ | |||||||
| | DEVOPS-LAUNCH-18-900 | TODO | DevOps Guild, Module Leads | Wave 0 completion | Collect “full implementation” sign-off from module owners and consolidate launch readiness checklist. | Sign-off record stored under `docs/ops/launch-readiness.md`; outstanding gaps triaged; checklist approved. | | | DEVOPS-LAUNCH-18-900 | TODO | DevOps Guild, Module Leads | Wave 0 completion | Collect “full implementation” sign-off from module owners and consolidate launch readiness checklist. | Sign-off record stored under `docs/ops/launch-readiness.md`; outstanding gaps triaged; checklist approved. | | ||||||
| | DEVOPS-LAUNCH-18-001 | TODO | DevOps Guild | DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 | Production launch cutover rehearsal and runbook publication. | `docs/ops/launch-cutover.md` drafted, rehearsal executed with rollback drill, approvals captured. | | | DEVOPS-LAUNCH-18-001 | TODO | DevOps Guild | DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 | Production launch cutover rehearsal and runbook publication. | `docs/ops/launch-cutover.md` drafted, rehearsal executed with rollback drill, approvals captured. | | ||||||
| | DEVOPS-NUGET-13-001 | TODO | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. | | | DEVOPS-NUGET-13-001 | TODO | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. | | ||||||
|  | | DEVOPS-NUGET-13-002 | TODO | DevOps Guild | DEVOPS-NUGET-13-001 | Ensure all solutions/projects prefer `local-nuget` before public sources and document restore order validation. | `NuGet.config` and solution-level configs resolve from `local-nuget` first; automated check verifies priority; docs updated for restore ordering. | | ||||||
|  | | DEVOPS-NUGET-13-003 | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-002 | Sweep `Microsoft.*` NuGet dependencies pinned to 8.* and upgrade to latest .NET 10 equivalents (or .NET 9 when 10 unavailable), updating restore guidance. | Dependency audit shows no 8.* `Microsoft.*` packages remaining; CI builds green; changelog/doc sections capture upgrade rationale. | | ||||||
| > Remark (2025-10-20): Repacked `Mongo2Go` local feed to require MongoDB.Driver 3.5.0 + SharpCompress 0.41.0; cache regression tests green and NU1902/NU1903 suppressed. | > Remark (2025-10-20): Repacked `Mongo2Go` local feed to require MongoDB.Driver 3.5.0 + SharpCompress 0.41.0; cache regression tests green and NU1902/NU1903 suppressed. | ||||||
| > Remark (2025-10-21): Compose/Helm profiles now surface `SCANNER__EVENTS__*` toggles with docs pointing at new `.env` placeholders. | > Remark (2025-10-21): Compose/Helm profiles now surface `SCANNER__EVENTS__*` toggles with docs pointing at new `.env` placeholders. | ||||||
|   | |||||||
| @@ -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" | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; | |||||||
| using StellaOps.Excititor.Attestation.Dsse; | using StellaOps.Excititor.Attestation.Dsse; | ||||||
| using StellaOps.Excititor.Attestation.Signing; | using StellaOps.Excititor.Attestation.Signing; | ||||||
| using StellaOps.Excititor.Attestation.Transparency; | using StellaOps.Excititor.Attestation.Transparency; | ||||||
|  | using StellaOps.Excititor.Attestation.Verification; | ||||||
| using StellaOps.Excititor.Core; | using StellaOps.Excititor.Core; | ||||||
|  |  | ||||||
| namespace StellaOps.Excititor.Attestation.Tests; | namespace StellaOps.Excititor.Attestation.Tests; | ||||||
| @@ -16,7 +17,8 @@ public sealed class VexAttestationClientTests | |||||||
|         var signer = new FakeSigner(); |         var signer = new FakeSigner(); | ||||||
|         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); |         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); | ||||||
|         var options = Options.Create(new VexAttestationClientOptions()); |         var options = Options.Create(new VexAttestationClientOptions()); | ||||||
|         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance); |         var verifier = new FakeVerifier(); | ||||||
|  |         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier); | ||||||
|  |  | ||||||
|         var request = new VexAttestationRequest( |         var request = new VexAttestationRequest( | ||||||
|             ExportId: "exports/456", |             ExportId: "exports/456", | ||||||
| @@ -41,7 +43,8 @@ public sealed class VexAttestationClientTests | |||||||
|         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); |         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); | ||||||
|         var options = Options.Create(new VexAttestationClientOptions()); |         var options = Options.Create(new VexAttestationClientOptions()); | ||||||
|         var transparency = new FakeTransparencyLogClient(); |         var transparency = new FakeTransparencyLogClient(); | ||||||
|         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, transparencyLogClient: transparency); |         var verifier = new FakeVerifier(); | ||||||
|  |         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparencyLogClient: transparency); | ||||||
|  |  | ||||||
|         var request = new VexAttestationRequest( |         var request = new VexAttestationRequest( | ||||||
|             ExportId: "exports/789", |             ExportId: "exports/789", | ||||||
| @@ -78,4 +81,10 @@ public sealed class VexAttestationClientTests | |||||||
|         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) |         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) | ||||||
|             => ValueTask.FromResult(true); |             => ValueTask.FromResult(true); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private sealed class FakeVerifier : IVexAttestationVerifier | ||||||
|  |     { | ||||||
|  |         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken) | ||||||
|  |             => ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,132 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  | using Microsoft.Extensions.Logging.Abstractions; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Excititor.Attestation.Dsse; | ||||||
|  | using StellaOps.Excititor.Attestation.Signing; | ||||||
|  | using StellaOps.Excititor.Attestation.Transparency; | ||||||
|  | using StellaOps.Excititor.Attestation.Verification; | ||||||
|  | using StellaOps.Excititor.Core; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Excititor.Attestation.Tests; | ||||||
|  |  | ||||||
|  | public sealed class VexAttestationVerifierTests : IDisposable | ||||||
|  | { | ||||||
|  |     private readonly VexAttestationMetrics _metrics = new(); | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches() | ||||||
|  |     { | ||||||
|  |         var (request, metadata, envelope) = await CreateSignedAttestationAsync(); | ||||||
|  |         var verifier = CreateVerifier(options => options.RequireTransparencyLog = false); | ||||||
|  |  | ||||||
|  |         var verification = await verifier.VerifyAsync( | ||||||
|  |             new VexAttestationVerificationRequest(request, metadata, envelope), | ||||||
|  |             CancellationToken.None); | ||||||
|  |  | ||||||
|  |         Assert.True(verification.IsValid); | ||||||
|  |         Assert.Equal("valid", verification.Diagnostics["result"]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch() | ||||||
|  |     { | ||||||
|  |         var (request, metadata, envelope) = await CreateSignedAttestationAsync(); | ||||||
|  |         var verifier = CreateVerifier(options => options.RequireTransparencyLog = false); | ||||||
|  |  | ||||||
|  |         var tamperedMetadata = new VexAttestationMetadata( | ||||||
|  |             metadata.PredicateType, | ||||||
|  |             metadata.Rekor, | ||||||
|  |             "sha256:deadbeef", | ||||||
|  |             metadata.SignedAt); | ||||||
|  |  | ||||||
|  |         var verification = await verifier.VerifyAsync( | ||||||
|  |             new VexAttestationVerificationRequest(request, tamperedMetadata, envelope), | ||||||
|  |             CancellationToken.None); | ||||||
|  |  | ||||||
|  |         Assert.False(verification.IsValid); | ||||||
|  |         Assert.Equal("invalid", verification.Diagnostics["result"]); | ||||||
|  |         Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured() | ||||||
|  |     { | ||||||
|  |         var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true); | ||||||
|  |         var transparency = new ThrowingTransparencyLogClient(); | ||||||
|  |         var verifier = CreateVerifier(options => | ||||||
|  |         { | ||||||
|  |             options.AllowOfflineTransparency = true; | ||||||
|  |             options.RequireTransparencyLog = true; | ||||||
|  |         }, transparency); | ||||||
|  |  | ||||||
|  |         var verification = await verifier.VerifyAsync( | ||||||
|  |             new VexAttestationVerificationRequest(request, metadata, envelope), | ||||||
|  |             CancellationToken.None); | ||||||
|  |  | ||||||
|  |         Assert.True(verification.IsValid); | ||||||
|  |         Assert.Equal("offline", verification.Diagnostics["rekor.state"]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(bool includeRekor = false) | ||||||
|  |     { | ||||||
|  |         var signer = new FakeSigner(); | ||||||
|  |         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); | ||||||
|  |         var options = Options.Create(new VexAttestationClientOptions()); | ||||||
|  |         var transparency = includeRekor ? new FakeTransparencyLogClient() : null; | ||||||
|  |         var verifier = CreateVerifier(options => options.RequireTransparencyLog = false); | ||||||
|  |         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparency); | ||||||
|  |  | ||||||
|  |         var request = new VexAttestationRequest( | ||||||
|  |             ExportId: "exports/unit-test", | ||||||
|  |             QuerySignature: new VexQuerySignature("filters"), | ||||||
|  |             Artifact: new VexContentAddress("sha256", "cafebabe"), | ||||||
|  |             Format: VexExportFormat.Json, | ||||||
|  |             CreatedAt: DateTimeOffset.UtcNow, | ||||||
|  |             SourceProviders: ImmutableArray.Create("provider-a"), | ||||||
|  |             Metadata: ImmutableDictionary<string, string>.Empty); | ||||||
|  |  | ||||||
|  |         var response = await client.SignAsync(request, CancellationToken.None); | ||||||
|  |         var envelope = response.Diagnostics["envelope"]; | ||||||
|  |         return (request, response.Attestation, envelope); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private VexAttestationVerifier CreateVerifier(Action<VexAttestationVerificationOptions>? configureOptions = null, ITransparencyLogClient? transparency = null) | ||||||
|  |     { | ||||||
|  |         var options = new VexAttestationVerificationOptions(); | ||||||
|  |         configureOptions?.Invoke(options); | ||||||
|  |         return new VexAttestationVerifier( | ||||||
|  |             NullLogger<VexAttestationVerifier>.Instance, | ||||||
|  |             transparency, | ||||||
|  |             Options.Create(options), | ||||||
|  |             _metrics); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void Dispose() | ||||||
|  |     { | ||||||
|  |         _metrics.Dispose(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class FakeSigner : IVexSigner | ||||||
|  |     { | ||||||
|  |         public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken) | ||||||
|  |             => ValueTask.FromResult(new VexSignedPayload("signature", "key")); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class FakeTransparencyLogClient : ITransparencyLogClient | ||||||
|  |     { | ||||||
|  |         public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken) | ||||||
|  |             => ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "42", null)); | ||||||
|  |  | ||||||
|  |         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) | ||||||
|  |             => ValueTask.FromResult(true); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class ThrowingTransparencyLogClient : ITransparencyLogClient | ||||||
|  |     { | ||||||
|  |         public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken) | ||||||
|  |             => throw new NotSupportedException(); | ||||||
|  |  | ||||||
|  |         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) | ||||||
|  |             => throw new HttpRequestException("rekor unavailable"); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -16,7 +16,7 @@ namespace StellaOps.Excititor.Attestation.Dsse; | |||||||
|  |  | ||||||
| public sealed class VexDsseBuilder | public sealed class VexDsseBuilder | ||||||
| { | { | ||||||
|     private const string PayloadType = "application/vnd.in-toto+json"; |     internal const string PayloadType = "application/vnd.in-toto+json"; | ||||||
|  |  | ||||||
|     private readonly IVexSigner _signer; |     private readonly IVexSigner _signer; | ||||||
|     private readonly ILogger<VexDsseBuilder> _logger; |     private readonly ILogger<VexDsseBuilder> _logger; | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| # EXCITITOR-ATTEST-01-003 - Verification & Observability Plan | # EXCITITOR-ATTEST-01-003 - Verification & Observability Plan | ||||||
|  |  | ||||||
| - **Date:** 2025-10-19 | - **Date:** 2025-10-19 | ||||||
| - **Status:** Draft | - **Status:** In progress (2025-10-22) | ||||||
| - **Owner:** Team Excititor Attestation | - **Owner:** Team Excititor Attestation | ||||||
| - **Related tasks:** EXCITITOR-ATTEST-01-003 (Wave 0), EXCITITOR-WEB-01-003/004, EXCITITOR-WORKER-01-003 | - **Related tasks:** EXCITITOR-ATTEST-01-003 (Wave 0), EXCITITOR-WEB-01-003/004, EXCITITOR-WORKER-01-003 | ||||||
| - **Prerequisites satisfied:** EXCITITOR-ATTEST-01-002 (Rekor v2 client integration) | - **Prerequisites satisfied:** EXCITITOR-ATTEST-01-002 (Rekor v2 client integration) | ||||||
| @@ -147,3 +147,11 @@ Metrics must register via static helper using `Meter` and support offline operat | |||||||
| - Metrics/logging names peer-reviewed to avoid collisions. | - Metrics/logging names peer-reviewed to avoid collisions. | ||||||
| - Test backlog items entered into respective `TASKS.md` once implementation starts. | - Test backlog items entered into respective `TASKS.md` once implementation starts. | ||||||
| - Documentation (this plan) linked from `TASKS.md` notes for discoverability. | - Documentation (this plan) linked from `TASKS.md` notes for discoverability. | ||||||
|  |  | ||||||
|  | ## 11. 2025-10-22 Progress Notes | ||||||
|  |  | ||||||
|  | - Implemented `IVexAttestationVerifier`/`VexAttestationVerifier` with structural validation (subject/predicate checks, digest comparison, Rekor probes) and diagnostics map. | ||||||
|  | - Added `VexAttestationVerificationOptions` (RequireTransparencyLog, AllowOfflineTransparency, MaxClockSkew) and wired configuration through WebService DI. | ||||||
|  | - Created `VexAttestationMetrics` (`excititor.attestation.verify_total`, `excititor.attestation.verify_duration_seconds`) and hooked into verification flow with component/rekor tags. | ||||||
|  | - `VexAttestationClient.VerifyAsync` now delegates to the verifier; DI registers metrics + verifier via `AddVexAttestation`. | ||||||
|  | - Added unit coverage in `VexAttestationVerifierTests` (happy path, digest mismatch, offline Rekor) and updated client/export/webservice stubs to new verification signature. | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| using StellaOps.Excititor.Attestation.Dsse; | using StellaOps.Excititor.Attestation.Dsse; | ||||||
| using StellaOps.Excititor.Attestation.Transparency; | using StellaOps.Excititor.Attestation.Transparency; | ||||||
|  | using StellaOps.Excititor.Attestation.Verification; | ||||||
| using StellaOps.Excititor.Core; | using StellaOps.Excititor.Core; | ||||||
|  |  | ||||||
| namespace StellaOps.Excititor.Attestation.Extensions; | namespace StellaOps.Excititor.Attestation.Extensions; | ||||||
| @@ -10,6 +11,8 @@ public static class VexAttestationServiceCollectionExtensions | |||||||
|     public static IServiceCollection AddVexAttestation(this IServiceCollection services) |     public static IServiceCollection AddVexAttestation(this IServiceCollection services) | ||||||
|     { |     { | ||||||
|         services.AddSingleton<VexDsseBuilder>(); |         services.AddSingleton<VexDsseBuilder>(); | ||||||
|  |         services.AddSingleton<VexAttestationMetrics>(); | ||||||
|  |         services.AddSingleton<IVexAttestationVerifier, VexAttestationVerifier>(); | ||||||
|         services.AddSingleton<IVexAttestationClient, VexAttestationClient>(); |         services.AddSingleton<IVexAttestationClient, VexAttestationClient>(); | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -4,4 +4,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md | |||||||
| |---|---|---|---| | |---|---|---|---| | ||||||
| |EXCITITOR-ATTEST-01-001 – In-toto predicate & DSSE builder|Team Excititor Attestation|EXCITITOR-CORE-01-001|**DONE (2025-10-16)** – Added deterministic in-toto predicate/statement models, DSSE envelope builder wired to signer abstraction, and attestation client producing metadata + diagnostics.| | |EXCITITOR-ATTEST-01-001 – In-toto predicate & DSSE builder|Team Excititor Attestation|EXCITITOR-CORE-01-001|**DONE (2025-10-16)** – Added deterministic in-toto predicate/statement models, DSSE envelope builder wired to signer abstraction, and attestation client producing metadata + diagnostics.| | ||||||
| |EXCITITOR-ATTEST-01-002 – Rekor v2 client integration|Team Excititor Attestation|EXCITITOR-ATTEST-01-001|**DONE (2025-10-16)** – Implemented Rekor HTTP client with retry/backoff, transparency log abstraction, DI helpers, and attestation client integration capturing Rekor metadata + diagnostics.| | |EXCITITOR-ATTEST-01-002 – Rekor v2 client integration|Team Excititor Attestation|EXCITITOR-ATTEST-01-001|**DONE (2025-10-16)** – Implemented Rekor HTTP client with retry/backoff, transparency log abstraction, DI helpers, and attestation client integration capturing Rekor metadata + diagnostics.| | ||||||
| |EXCITITOR-ATTEST-01-003 – Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-19) – Add verification helpers for Worker/WebService, metrics/logging hooks, and negative-path regression tests. Draft plan logged in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19).| | |EXCITITOR-ATTEST-01-003 – Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-22) – Continuing implementation: build `IVexAttestationVerifier`, wire metrics/logging, and add regression tests. Draft plan in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19) guides scope; updating with worknotes as progress lands.| | ||||||
|  |  | ||||||
|  | > Remark (2025-10-22): Added verifier implementation + metrics/tests; next steps include wiring into WebService/Worker flows and expanding negative-path coverage. | ||||||
|   | |||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using StellaOps.Excititor.Core; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Excititor.Attestation.Verification; | ||||||
|  |  | ||||||
|  | public interface IVexAttestationVerifier | ||||||
|  | { | ||||||
|  |     ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken); | ||||||
|  | } | ||||||
| @@ -0,0 +1,35 @@ | |||||||
|  | using System; | ||||||
|  | using System.Diagnostics.Metrics; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Excititor.Attestation.Verification; | ||||||
|  |  | ||||||
|  | public sealed class VexAttestationMetrics : IDisposable | ||||||
|  | { | ||||||
|  |     public const string MeterName = "StellaOps.Excititor.Attestation"; | ||||||
|  |     public const string MeterVersion = "1.0"; | ||||||
|  |  | ||||||
|  |     private readonly Meter _meter; | ||||||
|  |     private bool _disposed; | ||||||
|  |  | ||||||
|  |     public VexAttestationMetrics() | ||||||
|  |     { | ||||||
|  |         _meter = new Meter(MeterName, MeterVersion); | ||||||
|  |         VerifyTotal = _meter.CreateCounter<long>("excititor.attestation.verify_total", description: "Attestation verification attempts grouped by result/component/rekor."); | ||||||
|  |         VerifyDuration = _meter.CreateHistogram<double>("excititor.attestation.verify_duration_seconds", unit: "s", description: "Attestation verification latency in seconds."); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public Counter<long> VerifyTotal { get; } | ||||||
|  |  | ||||||
|  |     public Histogram<double> VerifyDuration { get; } | ||||||
|  |  | ||||||
|  |     public void Dispose() | ||||||
|  |     { | ||||||
|  |         if (_disposed) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _meter.Dispose(); | ||||||
|  |         _disposed = true; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | using System; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Excititor.Attestation.Verification; | ||||||
|  |  | ||||||
|  | public sealed class VexAttestationVerificationOptions | ||||||
|  | { | ||||||
|  |     private TimeSpan _maxClockSkew = TimeSpan.FromMinutes(5); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// When true, verification fails if no transparency record is present. | ||||||
|  |     /// </summary> | ||||||
|  |     public bool RequireTransparencyLog { get; set; } = true; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Allows verification to succeed when the transparency log cannot be reached. | ||||||
|  |     /// A diagnostic entry is still emitted to signal the degraded state. | ||||||
|  |     /// </summary> | ||||||
|  |     public bool AllowOfflineTransparency { get; set; } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Maximum tolerated clock skew between the attestation creation time and the verification context timestamp. | ||||||
|  |     /// </summary> | ||||||
|  |     public TimeSpan MaxClockSkew | ||||||
|  |     { | ||||||
|  |         get => _maxClockSkew; | ||||||
|  |         set => _maxClockSkew = value < TimeSpan.Zero ? TimeSpan.Zero : value; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,470 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Collections.Immutable; | ||||||
|  | using System.Diagnostics; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Text; | ||||||
|  | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Excititor.Attestation.Dsse; | ||||||
|  | using StellaOps.Excititor.Attestation.Models; | ||||||
|  | using StellaOps.Excititor.Attestation.Transparency; | ||||||
|  | using StellaOps.Excititor.Core; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Excititor.Attestation.Verification; | ||||||
|  |  | ||||||
|  | internal sealed class VexAttestationVerifier : IVexAttestationVerifier | ||||||
|  | { | ||||||
|  |     private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new() | ||||||
|  |     { | ||||||
|  |         PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||||
|  |         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     private static readonly JsonSerializerOptions StatementSerializerOptions = new() | ||||||
|  |     { | ||||||
|  |         PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||||
|  |         DefaultIgnoreCondition = JsonIgnoreCondition.Never, | ||||||
|  |         Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     private readonly ILogger<VexAttestationVerifier> _logger; | ||||||
|  |     private readonly ITransparencyLogClient? _transparencyLogClient; | ||||||
|  |     private readonly VexAttestationVerificationOptions _options; | ||||||
|  |     private readonly VexAttestationMetrics _metrics; | ||||||
|  |  | ||||||
|  |     public VexAttestationVerifier( | ||||||
|  |         ILogger<VexAttestationVerifier> logger, | ||||||
|  |         ITransparencyLogClient? transparencyLogClient, | ||||||
|  |         IOptions<VexAttestationVerificationOptions> options, | ||||||
|  |         VexAttestationMetrics metrics) | ||||||
|  |     { | ||||||
|  |         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|  |         ArgumentNullException.ThrowIfNull(options); | ||||||
|  |         _transparencyLogClient = transparencyLogClient; | ||||||
|  |         _options = options.Value; | ||||||
|  |         _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async ValueTask<VexAttestationVerification> VerifyAsync( | ||||||
|  |         VexAttestationVerificationRequest request, | ||||||
|  |         CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(request); | ||||||
|  |  | ||||||
|  |         var stopwatch = Stopwatch.StartNew(); | ||||||
|  |         var diagnostics = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal); | ||||||
|  |         var resultLabel = "valid"; | ||||||
|  |         var rekorState = "skipped"; | ||||||
|  |         var component = request.IsReverify ? "worker" : "webservice"; | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(request.Envelope)) | ||||||
|  |             { | ||||||
|  |                 diagnostics["envelope.state"] = "missing"; | ||||||
|  |                 _logger.LogWarning("Attestation envelope is missing for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!TryDeserializeEnvelope(request.Envelope, out var envelope, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Failed to deserialize attestation envelope for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.Equals(envelope.PayloadType, VexDsseBuilder.PayloadType, StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 diagnostics["payload.type"] = envelope.PayloadType ?? string.Empty; | ||||||
|  |                 _logger.LogWarning( | ||||||
|  |                     "Unexpected DSSE payload type {PayloadType} for export {ExportId}", | ||||||
|  |                     envelope.PayloadType, | ||||||
|  |                     request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (envelope.Signatures is null || envelope.Signatures.Length == 0) | ||||||
|  |             { | ||||||
|  |                 diagnostics["signature.state"] = "missing"; | ||||||
|  |                 _logger.LogWarning("Attestation envelope for export {ExportId} does not contain signatures.", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!TryDecodePayload(envelope.PayloadBase64, out var payloadBytes, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Failed to decode attestation payload for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!TryDeserializeStatement(payloadBytes, out var statement, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Failed to deserialize DSSE statement for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!ValidatePredicateType(statement, request, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Predicate type mismatch for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!ValidateSubject(statement, request, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Subject mismatch for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!ValidatePredicate(statement, request, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Predicate payload mismatch for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!ValidateMetadataDigest(envelope, request.Metadata, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Attestation digest mismatch for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!ValidateSignedAt(request.Metadata, request.Attestation.CreatedAt, diagnostics)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("SignedAt validation failed for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             rekorState = await VerifyTransparencyAsync(request.Metadata, diagnostics, cancellationToken).ConfigureAwait(false); | ||||||
|  |             if (rekorState is "missing" or "unverified" or "client_unavailable") | ||||||
|  |             { | ||||||
|  |                 resultLabel = "invalid"; | ||||||
|  |                 return BuildResult(false); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             diagnostics["signature.state"] = "present"; | ||||||
|  |             return BuildResult(true); | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             diagnostics["error"] = ex.GetType().Name; | ||||||
|  |             diagnostics["error.message"] = ex.Message; | ||||||
|  |             resultLabel = "error"; | ||||||
|  |             _logger.LogError(ex, "Unexpected exception verifying attestation for export {ExportId}", request.Attestation.ExportId); | ||||||
|  |             return BuildResult(false); | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             stopwatch.Stop(); | ||||||
|  |             var tags = new KeyValuePair<string, object?>[] | ||||||
|  |             { | ||||||
|  |                 new("result", resultLabel), | ||||||
|  |                 new("component", component), | ||||||
|  |                 new("rekor", rekorState), | ||||||
|  |             }; | ||||||
|  |             _metrics.VerifyTotal.Add(1, tags); | ||||||
|  |             _metrics.VerifyDuration.Record(stopwatch.Elapsed.TotalSeconds, tags); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         VexAttestationVerification BuildResult(bool isValid) | ||||||
|  |         { | ||||||
|  |             diagnostics["result"] = resultLabel; | ||||||
|  |             diagnostics["component"] = component; | ||||||
|  |             diagnostics["rekor.state"] = rekorState; | ||||||
|  |             return new VexAttestationVerification(isValid, diagnostics.ToImmutable()); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryDeserializeEnvelope( | ||||||
|  |         string envelopeJson, | ||||||
|  |         out DsseEnvelope envelope, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, EnvelopeSerializerOptions) | ||||||
|  |                 ?? throw new InvalidOperationException("Envelope deserialized to null."); | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             diagnostics["envelope.error"] = ex.GetType().Name; | ||||||
|  |             envelope = default!; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryDecodePayload( | ||||||
|  |         string payloadBase64, | ||||||
|  |         out byte[] payloadBytes, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             payloadBytes = Convert.FromBase64String(payloadBase64); | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         catch (FormatException) | ||||||
|  |         { | ||||||
|  |             diagnostics["payload.base64"] = "invalid"; | ||||||
|  |             payloadBytes = Array.Empty<byte>(); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryDeserializeStatement( | ||||||
|  |         byte[] payload, | ||||||
|  |         out VexInTotoStatement statement, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             statement = JsonSerializer.Deserialize<VexInTotoStatement>(payload, StatementSerializerOptions) | ||||||
|  |                 ?? throw new InvalidOperationException("Statement deserialized to null."); | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             diagnostics["payload.error"] = ex.GetType().Name; | ||||||
|  |             statement = default!; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool ValidatePredicateType( | ||||||
|  |         VexInTotoStatement statement, | ||||||
|  |         VexAttestationVerificationRequest request, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         var predicateType = statement.PredicateType ?? string.Empty; | ||||||
|  |         if (!string.Equals(predicateType, request.Metadata.PredicateType, StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.type"] = predicateType; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool ValidateSubject( | ||||||
|  |         VexInTotoStatement statement, | ||||||
|  |         VexAttestationVerificationRequest request, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         if (statement.Subject is null || statement.Subject.Count != 1) | ||||||
|  |         { | ||||||
|  |             diagnostics["subject.count"] = (statement.Subject?.Count ?? 0).ToString(); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var subject = statement.Subject[0]; | ||||||
|  |         if (!string.Equals(subject.Name, request.Attestation.ExportId, StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             diagnostics["subject.name"] = subject.Name ?? string.Empty; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (subject.Digest is null) | ||||||
|  |         { | ||||||
|  |             diagnostics["subject.digest"] = "missing"; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var algorithmKey = request.Attestation.Artifact.Algorithm.ToLowerInvariant(); | ||||||
|  |         if (!subject.Digest.TryGetValue(algorithmKey, out var digest) | ||||||
|  |             || !string.Equals(digest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase)) | ||||||
|  |         { | ||||||
|  |             diagnostics["subject.digest"] = digest ?? string.Empty; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private bool ValidatePredicate( | ||||||
|  |         VexInTotoStatement statement, | ||||||
|  |         VexAttestationVerificationRequest request, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         var predicate = statement.Predicate; | ||||||
|  |         if (predicate is null) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.state"] = "missing"; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.Equals(predicate.ExportId, request.Attestation.ExportId, StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.exportId"] = predicate.ExportId ?? string.Empty; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.Equals(predicate.QuerySignature, request.Attestation.QuerySignature.Value, StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.querySignature"] = predicate.QuerySignature ?? string.Empty; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.Equals(predicate.ArtifactAlgorithm, request.Attestation.Artifact.Algorithm, StringComparison.OrdinalIgnoreCase) | ||||||
|  |             || !string.Equals(predicate.ArtifactDigest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase)) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.artifact"] = $"{predicate.ArtifactAlgorithm}:{predicate.ArtifactDigest}"; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (predicate.Format != request.Attestation.Format) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.format"] = predicate.Format.ToString(); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var createdDelta = (predicate.CreatedAt - request.Attestation.CreatedAt).Duration(); | ||||||
|  |         if (createdDelta > _options.MaxClockSkew) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.createdAtDelta"] = createdDelta.ToString(); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!SetEquals(predicate.SourceProviders, request.Attestation.SourceProviders)) | ||||||
|  |         { | ||||||
|  |             diagnostics["predicate.sourceProviders"] = string.Join(",", predicate.SourceProviders ?? Array.Empty<string>()); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.Attestation.Metadata.Count > 0) | ||||||
|  |         { | ||||||
|  |             if (predicate.Metadata is null) | ||||||
|  |             { | ||||||
|  |                 diagnostics["predicate.metadata"] = "missing"; | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             foreach (var kvp in request.Attestation.Metadata) | ||||||
|  |             { | ||||||
|  |                 if (!predicate.Metadata.TryGetValue(kvp.Key, out var actual) | ||||||
|  |                     || !string.Equals(actual, kvp.Value, StringComparison.Ordinal)) | ||||||
|  |                 { | ||||||
|  |                     diagnostics[$"predicate.metadata.{kvp.Key}"] = actual ?? string.Empty; | ||||||
|  |                     return false; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private bool ValidateMetadataDigest( | ||||||
|  |         DsseEnvelope envelope, | ||||||
|  |         VexAttestationMetadata metadata, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(metadata.EnvelopeDigest)) | ||||||
|  |         { | ||||||
|  |             diagnostics["metadata.envelopeDigest"] = "missing"; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var computed = VexDsseBuilder.ComputeEnvelopeDigest(envelope); | ||||||
|  |         if (!string.Equals(computed, metadata.EnvelopeDigest, StringComparison.OrdinalIgnoreCase)) | ||||||
|  |         { | ||||||
|  |             diagnostics["metadata.envelopeDigest"] = metadata.EnvelopeDigest; | ||||||
|  |             diagnostics["metadata.envelopeDigest.computed"] = computed; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         diagnostics["metadata.envelopeDigest"] = "match"; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private bool ValidateSignedAt( | ||||||
|  |         VexAttestationMetadata metadata, | ||||||
|  |         DateTimeOffset createdAt, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics) | ||||||
|  |     { | ||||||
|  |         if (metadata.SignedAt is null) | ||||||
|  |         { | ||||||
|  |             diagnostics["metadata.signedAt"] = "missing"; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var delta = (metadata.SignedAt.Value - createdAt).Duration(); | ||||||
|  |         if (delta > _options.MaxClockSkew) | ||||||
|  |         { | ||||||
|  |             diagnostics["metadata.signedAtDelta"] = delta.ToString(); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async ValueTask<string> VerifyTransparencyAsync( | ||||||
|  |         VexAttestationMetadata metadata, | ||||||
|  |         ImmutableDictionary<string, string>.Builder diagnostics, | ||||||
|  |         CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (metadata.Rekor is null) | ||||||
|  |         { | ||||||
|  |             if (_options.RequireTransparencyLog) | ||||||
|  |             { | ||||||
|  |                 diagnostics["rekor.state"] = "missing"; | ||||||
|  |                 return "missing"; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             diagnostics["rekor.state"] = "disabled"; | ||||||
|  |             return "disabled"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (_transparencyLogClient is null) | ||||||
|  |         { | ||||||
|  |             diagnostics["rekor.state"] = "client_unavailable"; | ||||||
|  |             return _options.RequireTransparencyLog ? "client_unavailable" : "disabled"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var verified = await _transparencyLogClient.VerifyAsync(metadata.Rekor.Location, cancellationToken).ConfigureAwait(false); | ||||||
|  |             diagnostics["rekor.state"] = verified ? "verified" : "unverified"; | ||||||
|  |             return verified ? "verified" : "unverified"; | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             diagnostics["rekor.error"] = ex.GetType().Name; | ||||||
|  |             if (_options.AllowOfflineTransparency) | ||||||
|  |             { | ||||||
|  |                 diagnostics["rekor.state"] = "offline"; | ||||||
|  |                 return "offline"; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             diagnostics["rekor.state"] = "unreachable"; | ||||||
|  |             return "unreachable"; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool SetEquals(IReadOnlyCollection<string>? left, ImmutableArray<string> right) | ||||||
|  |     { | ||||||
|  |         if (left is null) | ||||||
|  |         { | ||||||
|  |             return right.IsDefaultOrEmpty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (left.Count != right.Length) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var leftSet = new HashSet<string>(left, StringComparer.Ordinal); | ||||||
|  |         return right.All(leftSet.Contains); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -10,6 +10,7 @@ using StellaOps.Excititor.Attestation.Dsse; | |||||||
| using StellaOps.Excititor.Attestation.Models; | using StellaOps.Excititor.Attestation.Models; | ||||||
| using StellaOps.Excititor.Attestation.Signing; | using StellaOps.Excititor.Attestation.Signing; | ||||||
| using StellaOps.Excititor.Attestation.Transparency; | using StellaOps.Excititor.Attestation.Transparency; | ||||||
|  | using StellaOps.Excititor.Attestation.Verification; | ||||||
| using StellaOps.Excititor.Core; | using StellaOps.Excititor.Core; | ||||||
|  |  | ||||||
| namespace StellaOps.Excititor.Attestation; | namespace StellaOps.Excititor.Attestation; | ||||||
| @@ -26,17 +27,20 @@ public sealed class VexAttestationClient : IVexAttestationClient | |||||||
|     private readonly TimeProvider _timeProvider; |     private readonly TimeProvider _timeProvider; | ||||||
|     private readonly IReadOnlyDictionary<string, string> _defaultMetadata; |     private readonly IReadOnlyDictionary<string, string> _defaultMetadata; | ||||||
|     private readonly ITransparencyLogClient? _transparencyLogClient; |     private readonly ITransparencyLogClient? _transparencyLogClient; | ||||||
|  |     private readonly IVexAttestationVerifier _verifier; | ||||||
|  |  | ||||||
|     public VexAttestationClient( |     public VexAttestationClient( | ||||||
|         VexDsseBuilder builder, |         VexDsseBuilder builder, | ||||||
|         IOptions<VexAttestationClientOptions> options, |         IOptions<VexAttestationClientOptions> options, | ||||||
|         ILogger<VexAttestationClient> logger, |         ILogger<VexAttestationClient> logger, | ||||||
|  |         IVexAttestationVerifier verifier, | ||||||
|         TimeProvider? timeProvider = null, |         TimeProvider? timeProvider = null, | ||||||
|         ITransparencyLogClient? transparencyLogClient = null) |         ITransparencyLogClient? transparencyLogClient = null) | ||||||
|     { |     { | ||||||
|         _builder = builder ?? throw new ArgumentNullException(nameof(builder)); |         _builder = builder ?? throw new ArgumentNullException(nameof(builder)); | ||||||
|         ArgumentNullException.ThrowIfNull(options); |         ArgumentNullException.ThrowIfNull(options); | ||||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); |         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|  |         _verifier = verifier ?? throw new ArgumentNullException(nameof(verifier)); | ||||||
|         _timeProvider = timeProvider ?? TimeProvider.System; |         _timeProvider = timeProvider ?? TimeProvider.System; | ||||||
|         _defaultMetadata = options.Value.DefaultMetadata; |         _defaultMetadata = options.Value.DefaultMetadata; | ||||||
|         _transparencyLogClient = transparencyLogClient; |         _transparencyLogClient = transparencyLogClient; | ||||||
| @@ -82,11 +86,10 @@ public sealed class VexAttestationClient : IVexAttestationClient | |||||||
|         return new VexAttestationResponse(metadata, diagnosticsBuilder); |         return new VexAttestationResponse(metadata, diagnosticsBuilder); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) |     public ValueTask<VexAttestationVerification> VerifyAsync( | ||||||
|     { |         VexAttestationVerificationRequest request, | ||||||
|         // Placeholder until verification flow is implemented in EXCITITOR-ATTEST-01-003. |         CancellationToken cancellationToken) | ||||||
|         return ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); |         => _verifier.VerifyAsync(request, cancellationToken); | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static IReadOnlyDictionary<string, string> MergeMetadata( |     private static IReadOnlyDictionary<string, string> MergeMetadata( | ||||||
|         IReadOnlyDictionary<string, string> requestMetadata, |         IReadOnlyDictionary<string, string> requestMetadata, | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ public interface IVexAttestationClient | |||||||
| { | { | ||||||
|     ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken); |     ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken); | ||||||
|  |  | ||||||
|     ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken); |     ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken); | ||||||
| } | } | ||||||
|  |  | ||||||
| public sealed record VexAttestationRequest( | public sealed record VexAttestationRequest( | ||||||
| @@ -25,6 +25,12 @@ public sealed record VexAttestationResponse( | |||||||
|     VexAttestationMetadata Attestation, |     VexAttestationMetadata Attestation, | ||||||
|     ImmutableDictionary<string, string> Diagnostics); |     ImmutableDictionary<string, string> Diagnostics); | ||||||
|  |  | ||||||
|  | public sealed record VexAttestationVerificationRequest( | ||||||
|  |     VexAttestationRequest Attestation, | ||||||
|  |     VexAttestationMetadata Metadata, | ||||||
|  |     string Envelope, | ||||||
|  |     bool IsReverify = false); | ||||||
|  |  | ||||||
| public sealed record VexAttestationVerification( | public sealed record VexAttestationVerification( | ||||||
|     bool IsValid, |     bool IsValid, | ||||||
|     ImmutableDictionary<string, string> Diagnostics); |     ImmutableDictionary<string, string> Diagnostics); | ||||||
|   | |||||||
| @@ -382,6 +382,16 @@ public static class VexCanonicalJsonSerializer | |||||||
|                 "diagnostics", |                 "diagnostics", | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |             typeof(VexAttestationVerificationRequest), | ||||||
|  |             new[] | ||||||
|  |             { | ||||||
|  |                 "attestation", | ||||||
|  |                 "metadata", | ||||||
|  |                 "envelope", | ||||||
|  |                 "isReverify", | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|             typeof(VexAttestationVerification), |             typeof(VexAttestationVerification), | ||||||
|             new[] |             new[] | ||||||
|   | |||||||
| @@ -290,7 +290,7 @@ public sealed class ExportEngineTests | |||||||
|             return ValueTask.FromResult(Response); |             return ValueTask.FromResult(Response); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) |         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken) | ||||||
|             => ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); |             => ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -140,7 +140,7 @@ internal static class TestServiceOverrides | |||||||
|         { |         { | ||||||
|             var envelope = new DsseEnvelope( |             var envelope = new DsseEnvelope( | ||||||
|                 Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"stub\":\"payload\"}")), |                 Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"stub\":\"payload\"}")), | ||||||
|                 "application/vnd.stellaops.resolve+json", |                 "application/vnd.in-toto+json", | ||||||
|                 new[] |                 new[] | ||||||
|                 { |                 { | ||||||
|                     new DsseSignature("attestation-signature", "attestation-key"), |                     new DsseSignature("attestation-signature", "attestation-key"), | ||||||
| @@ -149,13 +149,18 @@ internal static class TestServiceOverrides | |||||||
|             var diagnostics = ImmutableDictionary<string, string>.Empty |             var diagnostics = ImmutableDictionary<string, string>.Empty | ||||||
|                 .Add("envelope", JsonSerializer.Serialize(envelope)); |                 .Add("envelope", JsonSerializer.Serialize(envelope)); | ||||||
|  |  | ||||||
|  |             var metadata = new VexAttestationMetadata( | ||||||
|  |                 "stub", | ||||||
|  |                 envelopeDigest: VexDsseBuilder.ComputeEnvelopeDigest(envelope), | ||||||
|  |                 signedAt: DateTimeOffset.UtcNow); | ||||||
|  |  | ||||||
|             var response = new VexAttestationResponse( |             var response = new VexAttestationResponse( | ||||||
|                 new VexAttestationMetadata("stub"), |                 metadata, | ||||||
|                 diagnostics); |                 diagnostics); | ||||||
|             return ValueTask.FromResult(response); |             return ValueTask.FromResult(response); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) |         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken) | ||||||
|         { |         { | ||||||
|             var verification = new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty); |             var verification = new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty); | ||||||
|             return ValueTask.FromResult(verification); |             return ValueTask.FromResult(verification); | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ using System.Linq; | |||||||
| using System.Collections.Immutable; | using System.Collections.Immutable; | ||||||
| using Microsoft.AspNetCore.Authentication; | using Microsoft.AspNetCore.Authentication; | ||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Excititor.Attestation.Verification; | ||||||
| using StellaOps.Excititor.Attestation.Extensions; | using StellaOps.Excititor.Attestation.Extensions; | ||||||
| using StellaOps.Excititor.Attestation; | using StellaOps.Excititor.Attestation; | ||||||
| using StellaOps.Excititor.Attestation.Transparency; | using StellaOps.Excititor.Attestation.Transparency; | ||||||
| @@ -37,6 +38,7 @@ services.AddVexExportEngine(); | |||||||
| services.AddVexExportCacheServices(); | services.AddVexExportCacheServices(); | ||||||
| services.AddVexAttestation(); | services.AddVexAttestation(); | ||||||
| services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client")); | services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client")); | ||||||
|  | services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification")); | ||||||
| services.AddVexPolicy(); | services.AddVexPolicy(); | ||||||
| services.AddRedHatCsafConnector(); | services.AddRedHatCsafConnector(); | ||||||
| services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName)); | services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName)); | ||||||
|   | |||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | namespace StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||||
|  |  | ||||||
|  | public interface IDotNetAuthenticodeInspector | ||||||
|  | { | ||||||
|  |     DotNetAuthenticodeMetadata? TryInspect(string assemblyPath, CancellationToken cancellationToken); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public sealed record DotNetAuthenticodeMetadata( | ||||||
|  |     string? Subject, | ||||||
|  |     string? Issuer, | ||||||
|  |     DateTimeOffset? NotBefore, | ||||||
|  |     DateTimeOffset? NotAfter, | ||||||
|  |     string? Thumbprint, | ||||||
|  |     string? SerialNumber); | ||||||
| @@ -1,4 +1,8 @@ | |||||||
|  | using System.Diagnostics; | ||||||
|  | using System.Globalization; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reflection; | ||||||
|  | using System.Security.Cryptography; | ||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
|  |  | ||||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal; | namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal; | ||||||
| @@ -26,7 +30,7 @@ internal static class DotNetDependencyCollector | |||||||
|             return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(Array.Empty<DotNetPackage>()); |             return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(Array.Empty<DotNetPackage>()); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var aggregator = new DotNetPackageAggregator(); |         var aggregator = new DotNetPackageAggregator(context); | ||||||
|  |  | ||||||
|         foreach (var depsPath in depsFiles) |         foreach (var depsPath in depsFiles) | ||||||
|         { |         { | ||||||
| @@ -65,7 +69,7 @@ internal static class DotNetDependencyCollector | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var packages = aggregator.Build(); |         var packages = aggregator.Build(cancellationToken); | ||||||
|         return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(packages); |         return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(packages); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -83,8 +87,19 @@ internal static class DotNetDependencyCollector | |||||||
|  |  | ||||||
| internal sealed class DotNetPackageAggregator | internal sealed class DotNetPackageAggregator | ||||||
| { | { | ||||||
|  |     private readonly LanguageAnalyzerContext _context; | ||||||
|  |     private readonly IDotNetAuthenticodeInspector? _authenticodeInspector; | ||||||
|     private readonly Dictionary<string, DotNetPackageBuilder> _packages = new(StringComparer.Ordinal); |     private readonly Dictionary<string, DotNetPackageBuilder> _packages = new(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |     public DotNetPackageAggregator(LanguageAnalyzerContext context) | ||||||
|  |     { | ||||||
|  |         _context = context ?? throw new ArgumentNullException(nameof(context)); | ||||||
|  |         if (context.TryGetService<IDotNetAuthenticodeInspector>(out var inspector)) | ||||||
|  |         { | ||||||
|  |             _authenticodeInspector = inspector; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public void Add(DotNetDepsFile depsFile, DotNetRuntimeConfig? runtimeConfig) |     public void Add(DotNetDepsFile depsFile, DotNetRuntimeConfig? runtimeConfig) | ||||||
|     { |     { | ||||||
|         ArgumentNullException.ThrowIfNull(depsFile); |         ArgumentNullException.ThrowIfNull(depsFile); | ||||||
| @@ -101,7 +116,7 @@ internal sealed class DotNetPackageAggregator | |||||||
|  |  | ||||||
|             if (!_packages.TryGetValue(key, out var builder)) |             if (!_packages.TryGetValue(key, out var builder)) | ||||||
|             { |             { | ||||||
|                 builder = new DotNetPackageBuilder(library.Id, normalizedId, library.Version); |                 builder = new DotNetPackageBuilder(_context, _authenticodeInspector, library.Id, normalizedId, library.Version); | ||||||
|                 _packages[key] = builder; |                 _packages[key] = builder; | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -109,7 +124,7 @@ internal sealed class DotNetPackageAggregator | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public IReadOnlyList<DotNetPackage> Build() |     public IReadOnlyList<DotNetPackage> Build(CancellationToken cancellationToken) | ||||||
|     { |     { | ||||||
|         if (_packages.Count == 0) |         if (_packages.Count == 0) | ||||||
|         { |         { | ||||||
| @@ -119,7 +134,8 @@ internal sealed class DotNetPackageAggregator | |||||||
|         var items = new List<DotNetPackage>(_packages.Count); |         var items = new List<DotNetPackage>(_packages.Count); | ||||||
|         foreach (var builder in _packages.Values) |         foreach (var builder in _packages.Values) | ||||||
|         { |         { | ||||||
|             items.Add(builder.Build()); |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |             items.Add(builder.Build(cancellationToken)); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         items.Sort(static (left, right) => string.CompareOrdinal(left.ComponentKey, right.ComponentKey)); |         items.Sort(static (left, right) => string.CompareOrdinal(left.ComponentKey, right.ComponentKey)); | ||||||
| @@ -129,6 +145,9 @@ internal sealed class DotNetPackageAggregator | |||||||
|  |  | ||||||
| internal sealed class DotNetPackageBuilder | internal sealed class DotNetPackageBuilder | ||||||
| { | { | ||||||
|  |     private readonly LanguageAnalyzerContext _context; | ||||||
|  |     private readonly IDotNetAuthenticodeInspector? _authenticodeInspector; | ||||||
|  |  | ||||||
|     private readonly string _originalId; |     private readonly string _originalId; | ||||||
|     private readonly string _normalizedId; |     private readonly string _normalizedId; | ||||||
|     private readonly string _version; |     private readonly string _version; | ||||||
| @@ -147,10 +166,15 @@ internal sealed class DotNetPackageBuilder | |||||||
|     private readonly SortedSet<string> _runtimeConfigFrameworks = new(StringComparer.OrdinalIgnoreCase); |     private readonly SortedSet<string> _runtimeConfigFrameworks = new(StringComparer.OrdinalIgnoreCase); | ||||||
|     private readonly SortedSet<string> _runtimeConfigGraph = new(StringComparer.OrdinalIgnoreCase); |     private readonly SortedSet<string> _runtimeConfigGraph = new(StringComparer.OrdinalIgnoreCase); | ||||||
|  |  | ||||||
|  |     private readonly Dictionary<string, AssemblyMetadataAggregate> _assemblies = new(StringComparer.OrdinalIgnoreCase); | ||||||
|  |     private readonly Dictionary<string, NativeAssetAggregate> _nativeAssets = new(StringComparer.OrdinalIgnoreCase); | ||||||
|     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); |     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); | ||||||
|  |     private bool _usedByEntrypoint; | ||||||
|  |  | ||||||
|     public DotNetPackageBuilder(string originalId, string normalizedId, string version) |     public DotNetPackageBuilder(LanguageAnalyzerContext context, IDotNetAuthenticodeInspector? authenticodeInspector, string originalId, string normalizedId, string version) | ||||||
|     { |     { | ||||||
|  |         _context = context ?? throw new ArgumentNullException(nameof(context)); | ||||||
|  |         _authenticodeInspector = authenticodeInspector; | ||||||
|         _originalId = string.IsNullOrWhiteSpace(originalId) ? normalizedId : originalId.Trim(); |         _originalId = string.IsNullOrWhiteSpace(originalId) ? normalizedId : originalId.Trim(); | ||||||
|         _normalizedId = normalizedId; |         _normalizedId = normalizedId; | ||||||
|         _version = version ?? string.Empty; |         _version = version ?? string.Empty; | ||||||
| @@ -193,6 +217,8 @@ internal sealed class DotNetPackageBuilder | |||||||
|             AddIfPresent(_runtimeIdentifiers, rid); |             AddIfPresent(_runtimeIdentifiers, rid); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         AddRuntimeAssets(library); | ||||||
|  |  | ||||||
|         _evidence.Add(new LanguageComponentEvidence( |         _evidence.Add(new LanguageComponentEvidence( | ||||||
|             LanguageEvidenceKind.File, |             LanguageEvidenceKind.File, | ||||||
|             "deps.json", |             "deps.json", | ||||||
| @@ -202,34 +228,11 @@ internal sealed class DotNetPackageBuilder | |||||||
|  |  | ||||||
|         if (runtimeConfig is not null) |         if (runtimeConfig is not null) | ||||||
|         { |         { | ||||||
|             AddIfPresent(_runtimeConfigPaths, runtimeConfig.RelativePath); |             AddRuntimeConfig(runtimeConfig); | ||||||
|  |  | ||||||
|             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() |     public DotNetPackage Build(CancellationToken cancellationToken) | ||||||
|     { |     { | ||||||
|         var metadata = new List<KeyValuePair<string, string?>>(32) |         var metadata = new List<KeyValuePair<string, string?>>(32) | ||||||
|         { |         { | ||||||
| @@ -255,6 +258,12 @@ internal sealed class DotNetPackageBuilder | |||||||
|         AddIndexed(metadata, "runtimeconfig.framework", _runtimeConfigFrameworks); |         AddIndexed(metadata, "runtimeconfig.framework", _runtimeConfigFrameworks); | ||||||
|         AddIndexed(metadata, "runtimeconfig.graph", _runtimeConfigGraph); |         AddIndexed(metadata, "runtimeconfig.graph", _runtimeConfigGraph); | ||||||
|  |  | ||||||
|  |         var assemblies = CollectAssemblyMetadata(cancellationToken); | ||||||
|  |         AddAssemblyMetadata(metadata, assemblies); | ||||||
|  |  | ||||||
|  |         var nativeAssets = CollectNativeMetadata(cancellationToken); | ||||||
|  |         AddNativeMetadata(metadata, nativeAssets); | ||||||
|  |  | ||||||
|         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); |         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||||
|  |  | ||||||
|         var evidence = _evidence |         var evidence = _evidence | ||||||
| @@ -269,11 +278,231 @@ internal sealed class DotNetPackageBuilder | |||||||
|             version: _version, |             version: _version, | ||||||
|             metadata: metadata, |             metadata: metadata, | ||||||
|             evidence: evidence, |             evidence: evidence, | ||||||
|             usedByEntrypoint: false); |             usedByEntrypoint: _usedByEntrypoint); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private IReadOnlyList<AssemblyMetadataResult> CollectAssemblyMetadata(CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (_assemblies.Count == 0) | ||||||
|  |         { | ||||||
|  |             return Array.Empty<AssemblyMetadataResult>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var results = new List<AssemblyMetadataResult>(_assemblies.Count); | ||||||
|  |         foreach (var aggregate in _assemblies.Values.OrderBy(static aggregate => aggregate.AssetRelativePath, StringComparer.Ordinal)) | ||||||
|  |         { | ||||||
|  |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |             results.Add(aggregate.Build(_context, _authenticodeInspector, cancellationToken)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return results; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private IReadOnlyList<NativeAssetResult> CollectNativeMetadata(CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (_nativeAssets.Count == 0) | ||||||
|  |         { | ||||||
|  |             return Array.Empty<NativeAssetResult>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var results = new List<NativeAssetResult>(_nativeAssets.Count); | ||||||
|  |         foreach (var aggregate in _nativeAssets.Values.OrderBy(static aggregate => aggregate.AssetRelativePath, StringComparer.Ordinal)) | ||||||
|  |         { | ||||||
|  |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |             results.Add(aggregate.Build(_context, cancellationToken)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return results; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddAssemblyMetadata(ICollection<KeyValuePair<string, string?>> metadata, IReadOnlyList<AssemblyMetadataResult> assemblies) | ||||||
|  |     { | ||||||
|  |         if (assemblies.Count == 0) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for (var index = 0; index < assemblies.Count; index++) | ||||||
|  |         { | ||||||
|  |             var record = assemblies[index]; | ||||||
|  |             var prefix = $"assembly[{index}]"; | ||||||
|  |  | ||||||
|  |             if (record.UsedByEntrypoint) | ||||||
|  |             { | ||||||
|  |                 _usedByEntrypoint = true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.assetPath", record.AssetPath); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.path", record.RelativePath); | ||||||
|  |             AddIndexed(metadata, $"{prefix}.tfm", record.TargetFrameworks); | ||||||
|  |             AddIndexed(metadata, $"{prefix}.rid", record.RuntimeIdentifiers); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.version", record.AssemblyVersion); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.fileVersion", record.FileVersion); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.publicKeyToken", record.PublicKeyToken); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.strongName", record.StrongName); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.company", record.CompanyName); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.product", record.ProductName); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.productVersion", record.ProductVersion); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.fileDescription", record.FileDescription); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.sha256", record.Sha256); | ||||||
|  |  | ||||||
|  |             if (record.Authenticode is { } authenticode) | ||||||
|  |             { | ||||||
|  |                 AddIfPresent(metadata, $"{prefix}.authenticode.subject", authenticode.Subject); | ||||||
|  |                 AddIfPresent(metadata, $"{prefix}.authenticode.issuer", authenticode.Issuer); | ||||||
|  |                 AddIfPresent(metadata, $"{prefix}.authenticode.notBefore", FormatTimestamp(authenticode.NotBefore)); | ||||||
|  |                 AddIfPresent(metadata, $"{prefix}.authenticode.notAfter", FormatTimestamp(authenticode.NotAfter)); | ||||||
|  |                 AddIfPresent(metadata, $"{prefix}.authenticode.thumbprint", authenticode.Thumbprint); | ||||||
|  |                 AddIfPresent(metadata, $"{prefix}.authenticode.serialNumber", authenticode.SerialNumber); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrEmpty(record.RelativePath)) | ||||||
|  |             { | ||||||
|  |                 _evidence.Add(new LanguageComponentEvidence( | ||||||
|  |                     LanguageEvidenceKind.File, | ||||||
|  |                     Source: "assembly", | ||||||
|  |                     Locator: record.RelativePath!, | ||||||
|  |                     Value: record.AssetPath, | ||||||
|  |                     Sha256: record.Sha256)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddNativeMetadata(ICollection<KeyValuePair<string, string?>> metadata, IReadOnlyList<NativeAssetResult> nativeAssets) | ||||||
|  |     { | ||||||
|  |         if (nativeAssets.Count == 0) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for (var index = 0; index < nativeAssets.Count; index++) | ||||||
|  |         { | ||||||
|  |             var record = nativeAssets[index]; | ||||||
|  |             var prefix = $"native[{index}]"; | ||||||
|  |  | ||||||
|  |             if (record.UsedByEntrypoint) | ||||||
|  |             { | ||||||
|  |                 _usedByEntrypoint = true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.assetPath", record.AssetPath); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.path", record.RelativePath); | ||||||
|  |             AddIndexed(metadata, $"{prefix}.tfm", record.TargetFrameworks); | ||||||
|  |             AddIndexed(metadata, $"{prefix}.rid", record.RuntimeIdentifiers); | ||||||
|  |             AddIfPresent(metadata, $"{prefix}.sha256", record.Sha256); | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrEmpty(record.RelativePath)) | ||||||
|  |             { | ||||||
|  |                 _evidence.Add(new LanguageComponentEvidence( | ||||||
|  |                     LanguageEvidenceKind.File, | ||||||
|  |                     Source: "native", | ||||||
|  |                     Locator: record.RelativePath!, | ||||||
|  |                     Value: record.AssetPath, | ||||||
|  |                     Sha256: record.Sha256)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddRuntimeAssets(DotNetLibrary library) | ||||||
|  |     { | ||||||
|  |         foreach (var asset in library.RuntimeAssets) | ||||||
|  |         { | ||||||
|  |             switch (asset.Kind) | ||||||
|  |             { | ||||||
|  |                 case DotNetLibraryAssetKind.Runtime: | ||||||
|  |                     AddRuntimeAssemblyAsset(asset, library.PackagePath); | ||||||
|  |                     break; | ||||||
|  |                 case DotNetLibraryAssetKind.Native: | ||||||
|  |                     AddNativeAsset(asset, library.PackagePath); | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddRuntimeAssemblyAsset(DotNetLibraryAsset asset, string? packagePath) | ||||||
|  |     { | ||||||
|  |         var key = NormalizePath(asset.RelativePath); | ||||||
|  |         if (string.IsNullOrEmpty(key)) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!_assemblies.TryGetValue(key, out var aggregate)) | ||||||
|  |         { | ||||||
|  |             aggregate = new AssemblyMetadataAggregate(key); | ||||||
|  |             _assemblies[key] = aggregate; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         aggregate.AddManifestData(asset, packagePath); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddNativeAsset(DotNetLibraryAsset asset, string? packagePath) | ||||||
|  |     { | ||||||
|  |         var key = NormalizePath(asset.RelativePath); | ||||||
|  |         if (string.IsNullOrEmpty(key)) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!_nativeAssets.TryGetValue(key, out var aggregate)) | ||||||
|  |         { | ||||||
|  |             aggregate = new NativeAssetAggregate(key); | ||||||
|  |             _nativeAssets[key] = aggregate; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         aggregate.AddManifestData(asset, packagePath); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddRuntimeConfig(DotNetRuntimeConfig runtimeConfig) | ||||||
|  |     { | ||||||
|  |         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)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static void AddIfPresent(ICollection<KeyValuePair<string, string?>> metadata, string key, string? value) | ||||||
|  |     { | ||||||
|  |         if (metadata is null) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentNullException(nameof(metadata)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         metadata.Add(new KeyValuePair<string, string?>(key, value)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static void AddIfPresent(ISet<string> set, string? value, bool normalizeLower = false) |     private static void AddIfPresent(ISet<string> set, string? value, bool normalizeLower = false) | ||||||
|     { |     { | ||||||
|  |         if (set is null) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentNullException(nameof(set)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if (string.IsNullOrWhiteSpace(value)) |         if (string.IsNullOrWhiteSpace(value)) | ||||||
|         { |         { | ||||||
|             return; |             return; | ||||||
| @@ -322,6 +551,148 @@ internal sealed class DotNetPackageBuilder | |||||||
|         return path.Replace('\\', '/'); |         return path.Replace('\\', '/'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private static string NormalizePath(string? path) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(path)) | ||||||
|  |         { | ||||||
|  |             return string.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var normalized = path.Replace('\\', '/').Trim(); | ||||||
|  |         return string.IsNullOrEmpty(normalized) ? string.Empty : normalized; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string ConvertToPlatformPath(string path) | ||||||
|  |         => string.IsNullOrEmpty(path) ? "." : path.Replace('/', Path.DirectorySeparatorChar); | ||||||
|  |  | ||||||
|  |     private static string CombineRelative(string basePath, string relativePath) | ||||||
|  |     { | ||||||
|  |         var left = NormalizePath(basePath); | ||||||
|  |         var right = NormalizePath(relativePath); | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrEmpty(left)) | ||||||
|  |         { | ||||||
|  |             return right; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrEmpty(right)) | ||||||
|  |         { | ||||||
|  |             return left; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return NormalizePath($"{left}/{right}"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? ComputeSha256(string path) | ||||||
|  |     { | ||||||
|  |         using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|  |         using var sha = SHA256.Create(); | ||||||
|  |         var hash = sha.ComputeHash(stream); | ||||||
|  |         return Convert.ToHexString(hash).ToLowerInvariant(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static AssemblyName? TryGetAssemblyName(string path) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             return AssemblyName.GetAssemblyName(path); | ||||||
|  |         } | ||||||
|  |         catch (FileNotFoundException) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         catch (BadImageFormatException) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         catch (FileLoadException) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static FileVersionInfo? TryGetFileVersionInfo(string path) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             return FileVersionInfo.GetVersionInfo(path); | ||||||
|  |         } | ||||||
|  |         catch (FileNotFoundException) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         catch (IOException) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         catch (UnauthorizedAccessException) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? FormatPublicKeyToken(byte[]? token) | ||||||
|  |     { | ||||||
|  |         if (token is null || token.Length == 0) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return Convert.ToHexString(token).ToLowerInvariant(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? BuildStrongName(AssemblyName assemblyName, string? publicKeyToken) | ||||||
|  |     { | ||||||
|  |         if (assemblyName is null || string.IsNullOrWhiteSpace(assemblyName.Name)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var version = assemblyName.Version?.ToString() ?? "0.0.0.0"; | ||||||
|  |         var culture = string.IsNullOrWhiteSpace(assemblyName.CultureName) ? "neutral" : assemblyName.CultureName; | ||||||
|  |         var token = string.IsNullOrWhiteSpace(publicKeyToken) ? "null" : publicKeyToken; | ||||||
|  |         return $"{assemblyName.Name}, Version={version}, Culture={culture}, PublicKeyToken={token}"; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? NormalizeMetadataValue(string? value) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return value.Trim(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? FormatTimestamp(DateTimeOffset? value) | ||||||
|  |     { | ||||||
|  |         if (value is null) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return value.Value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static IEnumerable<string> EnumeratePackageBases(string packagePath) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(packagePath)) | ||||||
|  |         { | ||||||
|  |             yield break; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var normalized = NormalizePath(packagePath); | ||||||
|  |         if (string.IsNullOrEmpty(normalized)) | ||||||
|  |         { | ||||||
|  |             yield break; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         yield return normalized; | ||||||
|  |         yield return NormalizePath($".nuget/packages/{normalized}"); | ||||||
|  |         yield return NormalizePath($"packages/{normalized}"); | ||||||
|  |         yield return NormalizePath($"usr/share/dotnet/packs/{normalized}"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private static string BuildRuntimeGraphValue(string rid, IReadOnlyList<string> fallbacks) |     private static string BuildRuntimeGraphValue(string rid, IReadOnlyList<string> fallbacks) | ||||||
|     { |     { | ||||||
|         if (string.IsNullOrWhiteSpace(rid)) |         if (string.IsNullOrWhiteSpace(rid)) | ||||||
| @@ -346,6 +717,343 @@ internal sealed class DotNetPackageBuilder | |||||||
|             : $"{rid.Trim()}=>{string.Join(';', ordered)}"; |             : $"{rid.Trim()}=>{string.Join(';', ordered)}"; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private sealed class AssemblyMetadataAggregate | ||||||
|  |     { | ||||||
|  |         private readonly string _assetRelativePath; | ||||||
|  |         private readonly SortedSet<string> _tfms = new(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         private readonly SortedSet<string> _runtimeIdentifiers = new(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         private readonly SortedSet<string> _packagePaths = new(StringComparer.Ordinal); | ||||||
|  |         private string? _assemblyVersion; | ||||||
|  |         private string? _fileVersion; | ||||||
|  |  | ||||||
|  |         public AssemblyMetadataAggregate(string assetRelativePath) | ||||||
|  |         { | ||||||
|  |             _assetRelativePath = NormalizePath(assetRelativePath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public string AssetRelativePath => _assetRelativePath; | ||||||
|  |  | ||||||
|  |         public void AddManifestData(DotNetLibraryAsset asset, string? packagePath) | ||||||
|  |         { | ||||||
|  |             if (!string.IsNullOrWhiteSpace(asset.TargetFramework)) | ||||||
|  |             { | ||||||
|  |                 _tfms.Add(asset.TargetFramework); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(asset.RuntimeIdentifier)) | ||||||
|  |             { | ||||||
|  |                 _runtimeIdentifiers.Add(asset.RuntimeIdentifier); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(asset.AssemblyVersion) && string.IsNullOrEmpty(_assemblyVersion)) | ||||||
|  |             { | ||||||
|  |                 _assemblyVersion = asset.AssemblyVersion; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(asset.FileVersion) && string.IsNullOrEmpty(_fileVersion)) | ||||||
|  |             { | ||||||
|  |                 _fileVersion = asset.FileVersion; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(packagePath)) | ||||||
|  |             { | ||||||
|  |                 var normalized = NormalizePath(packagePath); | ||||||
|  |                 if (!string.IsNullOrEmpty(normalized)) | ||||||
|  |                 { | ||||||
|  |                     _packagePaths.Add(normalized); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public AssemblyMetadataResult Build(LanguageAnalyzerContext context, IDotNetAuthenticodeInspector? authenticodeInspector, CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             var fileMetadata = ResolveFileMetadata(context, authenticodeInspector, cancellationToken); | ||||||
|  |  | ||||||
|  |             var assemblyName = fileMetadata?.AssemblyName; | ||||||
|  |             var versionInfo = fileMetadata?.FileVersionInfo; | ||||||
|  |  | ||||||
|  |             var assemblyVersion = assemblyName?.Version?.ToString() ?? _assemblyVersion; | ||||||
|  |             var fileVersion = !string.IsNullOrWhiteSpace(versionInfo?.FileVersion) ? versionInfo?.FileVersion : _fileVersion; | ||||||
|  |             var usedByEntrypoint = fileMetadata?.UsedByEntrypoint ?? false; | ||||||
|  |  | ||||||
|  |             string? publicKeyToken = null; | ||||||
|  |             string? strongName = null; | ||||||
|  |             if (assemblyName is not null) | ||||||
|  |             { | ||||||
|  |                 publicKeyToken = FormatPublicKeyToken(assemblyName.GetPublicKeyToken()); | ||||||
|  |                 strongName = BuildStrongName(assemblyName, publicKeyToken); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return new AssemblyMetadataResult( | ||||||
|  |                 AssetPath: _assetRelativePath, | ||||||
|  |                 RelativePath: fileMetadata?.RelativePath, | ||||||
|  |                 TargetFrameworks: _tfms.ToArray(), | ||||||
|  |                 RuntimeIdentifiers: _runtimeIdentifiers.ToArray(), | ||||||
|  |                 AssemblyVersion: assemblyVersion, | ||||||
|  |                 FileVersion: fileVersion, | ||||||
|  |                 PublicKeyToken: publicKeyToken, | ||||||
|  |                 StrongName: strongName, | ||||||
|  |                 CompanyName: NormalizeMetadataValue(versionInfo?.CompanyName), | ||||||
|  |                 ProductName: NormalizeMetadataValue(versionInfo?.ProductName), | ||||||
|  |                 ProductVersion: NormalizeMetadataValue(versionInfo?.ProductVersion), | ||||||
|  |                 FileDescription: NormalizeMetadataValue(versionInfo?.FileDescription), | ||||||
|  |                 Sha256: fileMetadata?.Sha256, | ||||||
|  |                 Authenticode: fileMetadata?.Authenticode, | ||||||
|  |                 UsedByEntrypoint: usedByEntrypoint); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private AssemblyFileMetadata? ResolveFileMetadata(LanguageAnalyzerContext context, IDotNetAuthenticodeInspector? authenticodeInspector, CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             var candidates = BuildCandidateRelativePaths(); | ||||||
|  |  | ||||||
|  |             foreach (var candidate in candidates) | ||||||
|  |             { | ||||||
|  |                 cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |                 var absolutePath = context.ResolvePath(ConvertToPlatformPath(candidate)); | ||||||
|  |                 if (!File.Exists(absolutePath)) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 try | ||||||
|  |                 { | ||||||
|  |                     var relativePath = NormalizePath(context.GetRelativePath(absolutePath)); | ||||||
|  |                     var sha256 = ComputeSha256(absolutePath); | ||||||
|  |                     var assemblyName = TryGetAssemblyName(absolutePath); | ||||||
|  |                     var versionInfo = TryGetFileVersionInfo(absolutePath); | ||||||
|  |  | ||||||
|  |                     DotNetAuthenticodeMetadata? authenticode = null; | ||||||
|  |                     if (authenticodeInspector is not null) | ||||||
|  |                     { | ||||||
|  |                         try | ||||||
|  |                         { | ||||||
|  |                             authenticode = authenticodeInspector.TryInspect(absolutePath, cancellationToken); | ||||||
|  |                         } | ||||||
|  |                         catch | ||||||
|  |                         { | ||||||
|  |                             authenticode = null; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var usedByEntrypoint = context.UsageHints.IsPathUsed(absolutePath); | ||||||
|  |  | ||||||
|  |                     return new AssemblyFileMetadata( | ||||||
|  |                         AbsolutePath: absolutePath, | ||||||
|  |                         RelativePath: relativePath, | ||||||
|  |                         Sha256: sha256, | ||||||
|  |                         AssemblyName: assemblyName, | ||||||
|  |                         FileVersionInfo: versionInfo, | ||||||
|  |                         Authenticode: authenticode, | ||||||
|  |                         UsedByEntrypoint: usedByEntrypoint); | ||||||
|  |                 } | ||||||
|  |                 catch (IOException) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |                 catch (UnauthorizedAccessException) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |                 catch (BadImageFormatException) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private IEnumerable<string> BuildCandidateRelativePaths() | ||||||
|  |         { | ||||||
|  |             var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||||
|  |  | ||||||
|  |             if (_packagePaths.Count > 0) | ||||||
|  |             { | ||||||
|  |                 foreach (var packagePath in _packagePaths) | ||||||
|  |                 { | ||||||
|  |                     foreach (var basePath in EnumeratePackageBases(packagePath)) | ||||||
|  |                     { | ||||||
|  |                         var combined = CombineRelative(basePath, _assetRelativePath); | ||||||
|  |                         if (string.IsNullOrEmpty(combined)) | ||||||
|  |                         { | ||||||
|  |                             continue; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if (seen.Add(combined)) | ||||||
|  |                         { | ||||||
|  |                             yield return combined; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (seen.Add(_assetRelativePath)) | ||||||
|  |             { | ||||||
|  |                 yield return _assetRelativePath; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class NativeAssetAggregate | ||||||
|  |     { | ||||||
|  |         private readonly string _assetRelativePath; | ||||||
|  |         private readonly SortedSet<string> _tfms = new(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         private readonly SortedSet<string> _runtimeIdentifiers = new(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         private readonly SortedSet<string> _packagePaths = new(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |         public NativeAssetAggregate(string assetRelativePath) | ||||||
|  |         { | ||||||
|  |             _assetRelativePath = NormalizePath(assetRelativePath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public string AssetRelativePath => _assetRelativePath; | ||||||
|  |  | ||||||
|  |         public void AddManifestData(DotNetLibraryAsset asset, string? packagePath) | ||||||
|  |         { | ||||||
|  |             if (!string.IsNullOrWhiteSpace(asset.TargetFramework)) | ||||||
|  |             { | ||||||
|  |                 _tfms.Add(asset.TargetFramework); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(asset.RuntimeIdentifier)) | ||||||
|  |             { | ||||||
|  |                 _runtimeIdentifiers.Add(asset.RuntimeIdentifier); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(packagePath)) | ||||||
|  |             { | ||||||
|  |                 var normalized = NormalizePath(packagePath); | ||||||
|  |                 if (!string.IsNullOrEmpty(normalized)) | ||||||
|  |                 { | ||||||
|  |                     _packagePaths.Add(normalized); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public NativeAssetResult Build(LanguageAnalyzerContext context, CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             var fileMetadata = ResolveFileMetadata(context, cancellationToken); | ||||||
|  |  | ||||||
|  |             return new NativeAssetResult( | ||||||
|  |                 AssetPath: _assetRelativePath, | ||||||
|  |                 RelativePath: fileMetadata?.RelativePath, | ||||||
|  |                 TargetFrameworks: _tfms.ToArray(), | ||||||
|  |                 RuntimeIdentifiers: _runtimeIdentifiers.ToArray(), | ||||||
|  |                 Sha256: fileMetadata?.Sha256, | ||||||
|  |                 UsedByEntrypoint: fileMetadata?.UsedByEntrypoint ?? false); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private NativeAssetFileMetadata? ResolveFileMetadata(LanguageAnalyzerContext context, CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             var candidates = BuildCandidateRelativePaths(); | ||||||
|  |  | ||||||
|  |             foreach (var candidate in candidates) | ||||||
|  |             { | ||||||
|  |                 cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |                 var absolutePath = context.ResolvePath(ConvertToPlatformPath(candidate)); | ||||||
|  |                 var usedByEntrypoint = context.UsageHints.IsPathUsed(absolutePath); | ||||||
|  |  | ||||||
|  |                 if (!File.Exists(absolutePath)) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 try | ||||||
|  |                 { | ||||||
|  |                     var relativePath = NormalizePath(context.GetRelativePath(absolutePath)); | ||||||
|  |                     var sha256 = ComputeSha256(absolutePath); | ||||||
|  |                     return new NativeAssetFileMetadata( | ||||||
|  |                         AbsolutePath: absolutePath, | ||||||
|  |                         RelativePath: relativePath, | ||||||
|  |                         Sha256: sha256, | ||||||
|  |                         UsedByEntrypoint: usedByEntrypoint); | ||||||
|  |                 } | ||||||
|  |                 catch (IOException) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |                 catch (UnauthorizedAccessException) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private IEnumerable<string> BuildCandidateRelativePaths() | ||||||
|  |         { | ||||||
|  |             var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||||
|  |  | ||||||
|  |             if (_packagePaths.Count > 0) | ||||||
|  |             { | ||||||
|  |                 foreach (var packagePath in _packagePaths) | ||||||
|  |                 { | ||||||
|  |                     foreach (var basePath in EnumeratePackageBases(packagePath)) | ||||||
|  |                     { | ||||||
|  |                         var combined = CombineRelative(basePath, _assetRelativePath); | ||||||
|  |                         if (string.IsNullOrEmpty(combined)) | ||||||
|  |                         { | ||||||
|  |                             continue; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if (seen.Add(combined)) | ||||||
|  |                         { | ||||||
|  |                             yield return combined; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (seen.Add(_assetRelativePath)) | ||||||
|  |             { | ||||||
|  |                 yield return _assetRelativePath; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed record AssemblyMetadataResult( | ||||||
|  |         string AssetPath, | ||||||
|  |         string? RelativePath, | ||||||
|  |         IReadOnlyList<string> TargetFrameworks, | ||||||
|  |         IReadOnlyList<string> RuntimeIdentifiers, | ||||||
|  |         string? AssemblyVersion, | ||||||
|  |         string? FileVersion, | ||||||
|  |         string? PublicKeyToken, | ||||||
|  |         string? StrongName, | ||||||
|  |         string? CompanyName, | ||||||
|  |         string? ProductName, | ||||||
|  |         string? ProductVersion, | ||||||
|  |         string? FileDescription, | ||||||
|  |         string? Sha256, | ||||||
|  |         DotNetAuthenticodeMetadata? Authenticode, | ||||||
|  |         bool UsedByEntrypoint); | ||||||
|  |  | ||||||
|  |     private sealed record NativeAssetResult( | ||||||
|  |         string AssetPath, | ||||||
|  |         string? RelativePath, | ||||||
|  |         IReadOnlyList<string> TargetFrameworks, | ||||||
|  |         IReadOnlyList<string> RuntimeIdentifiers, | ||||||
|  |         string? Sha256, | ||||||
|  |         bool UsedByEntrypoint); | ||||||
|  |  | ||||||
|  |     private sealed record AssemblyFileMetadata( | ||||||
|  |         string AbsolutePath, | ||||||
|  |         string? RelativePath, | ||||||
|  |         string? Sha256, | ||||||
|  |         AssemblyName? AssemblyName, | ||||||
|  |         FileVersionInfo? FileVersionInfo, | ||||||
|  |         DotNetAuthenticodeMetadata? Authenticode, | ||||||
|  |         bool UsedByEntrypoint); | ||||||
|  |  | ||||||
|  |     private sealed record NativeAssetFileMetadata( | ||||||
|  |         string AbsolutePath, | ||||||
|  |         string? RelativePath, | ||||||
|  |         string? Sha256, | ||||||
|  |         bool UsedByEntrypoint); | ||||||
|  |  | ||||||
|     private sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence> |     private sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence> | ||||||
|     { |     { | ||||||
|         public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y) |         public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y) | ||||||
|   | |||||||
| @@ -98,7 +98,7 @@ internal sealed class DotNetDepsFile | |||||||
|                     library.AddRuntimeIdentifier(rid); |                     library.AddRuntimeIdentifier(rid); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 library.MergeTargetMetadata(libraryProperty.Value); |                 library.MergeTargetMetadata(libraryProperty.Value, tfm, rid); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -126,6 +126,7 @@ internal sealed class DotNetLibrary | |||||||
| { | { | ||||||
|     private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase); |     private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase); | ||||||
|     private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal); |     private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal); | ||||||
|  |     private readonly List<DotNetLibraryAsset> _runtimeAssets = new(); | ||||||
|     private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal); |     private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|     private DotNetLibrary( |     private DotNetLibrary( | ||||||
| @@ -172,6 +173,8 @@ internal sealed class DotNetLibrary | |||||||
|  |  | ||||||
|     public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers; |     public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers; | ||||||
|  |  | ||||||
|  |     public IReadOnlyCollection<DotNetLibraryAsset> RuntimeAssets => _runtimeAssets; | ||||||
|  |  | ||||||
|     public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library) |     public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library) | ||||||
|     { |     { | ||||||
|         library = null; |         library = null; | ||||||
| @@ -230,29 +233,67 @@ internal sealed class DotNetLibrary | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public void MergeTargetMetadata(JsonElement element) |     public void MergeTargetMetadata(JsonElement element, string? tfm, string? rid) | ||||||
|     { |     { | ||||||
|         if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object) |         if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object) | ||||||
|         { |         { | ||||||
|             return; |             foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||||
|  |             { | ||||||
|  |                 AddDependency(dependencyProperty.Name); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) |         MergeRuntimeAssets(element, tfm, rid); | ||||||
|         { |  | ||||||
|             AddDependency(dependencyProperty.Name); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public void MergeLibraryMetadata(JsonElement element) |     public void MergeLibraryMetadata(JsonElement element) | ||||||
|     { |     { | ||||||
|         if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object) |         if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object) | ||||||
|  |         { | ||||||
|  |             foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||||
|  |             { | ||||||
|  |                 AddDependency(dependencyProperty.Name); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         MergeRuntimeAssets(element, tfm: null, rid: null); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void MergeRuntimeAssets(JsonElement element, string? tfm, string? rid) | ||||||
|  |     { | ||||||
|  |         AddRuntimeAssetsFromRuntime(element, tfm, rid); | ||||||
|  |         AddRuntimeAssetsFromRuntimeTargets(element, tfm, rid); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddRuntimeAssetsFromRuntime(JsonElement element, string? tfm, string? rid) | ||||||
|  |     { | ||||||
|  |         if (!element.TryGetProperty("runtime", out var runtimeElement) || runtimeElement.ValueKind is not JsonValueKind.Object) | ||||||
|         { |         { | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) |         foreach (var assetProperty in runtimeElement.EnumerateObject()) | ||||||
|         { |         { | ||||||
|             AddDependency(dependencyProperty.Name); |             if (DotNetLibraryAsset.TryCreateFromRuntime(assetProperty.Name, assetProperty.Value, tfm, rid, out var asset)) | ||||||
|  |             { | ||||||
|  |                 _runtimeAssets.Add(asset); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddRuntimeAssetsFromRuntimeTargets(JsonElement element, string? tfm, string? rid) | ||||||
|  |     { | ||||||
|  |         if (!element.TryGetProperty("runtimeTargets", out var runtimeTargetsElement) || runtimeTargetsElement.ValueKind is not JsonValueKind.Object) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach (var assetProperty in runtimeTargetsElement.EnumerateObject()) | ||||||
|  |         { | ||||||
|  |             if (DotNetLibraryAsset.TryCreateFromRuntimeTarget(assetProperty.Name, assetProperty.Value, tfm, rid, out var asset)) | ||||||
|  |             { | ||||||
|  |                 _runtimeAssets.Add(asset); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -316,3 +357,162 @@ internal sealed class DotNetLibrary | |||||||
|         return value.Trim(); |         return value.Trim(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | internal enum DotNetLibraryAssetKind | ||||||
|  | { | ||||||
|  |     Runtime, | ||||||
|  |     Native | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal sealed record DotNetLibraryAsset( | ||||||
|  |     string RelativePath, | ||||||
|  |     string? TargetFramework, | ||||||
|  |     string? RuntimeIdentifier, | ||||||
|  |     string? AssemblyVersion, | ||||||
|  |     string? FileVersion, | ||||||
|  |     DotNetLibraryAssetKind Kind) | ||||||
|  | { | ||||||
|  |     public static bool TryCreateFromRuntime(string name, JsonElement element, string? tfm, string? rid, [NotNullWhen(true)] out DotNetLibraryAsset? asset) | ||||||
|  |     { | ||||||
|  |         asset = null; | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(name)) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var normalizedPath = NormalizePath(name); | ||||||
|  |         if (string.IsNullOrEmpty(normalizedPath)) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!IsManagedAssembly(normalizedPath)) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         string? assemblyVersion = null; | ||||||
|  |         string? fileVersion = null; | ||||||
|  |  | ||||||
|  |         if (element.ValueKind == JsonValueKind.Object) | ||||||
|  |         { | ||||||
|  |             if (element.TryGetProperty("assemblyVersion", out var assemblyVersionElement) && assemblyVersionElement.ValueKind == JsonValueKind.String) | ||||||
|  |             { | ||||||
|  |                 assemblyVersion = NormalizeValue(assemblyVersionElement.GetString()); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (element.TryGetProperty("fileVersion", out var fileVersionElement) && fileVersionElement.ValueKind == JsonValueKind.String) | ||||||
|  |             { | ||||||
|  |                 fileVersion = NormalizeValue(fileVersionElement.GetString()); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         asset = new DotNetLibraryAsset( | ||||||
|  |             RelativePath: normalizedPath, | ||||||
|  |             TargetFramework: NormalizeValue(tfm), | ||||||
|  |             RuntimeIdentifier: NormalizeValue(rid), | ||||||
|  |             AssemblyVersion: assemblyVersion, | ||||||
|  |             FileVersion: fileVersion, | ||||||
|  |             Kind: DotNetLibraryAssetKind.Runtime); | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static bool TryCreateFromRuntimeTarget(string name, JsonElement element, string? tfm, string? rid, [NotNullWhen(true)] out DotNetLibraryAsset? asset) | ||||||
|  |     { | ||||||
|  |         asset = null; | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(name) || element.ValueKind is not JsonValueKind.Object) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var assetType = element.TryGetProperty("assetType", out var assetTypeElement) && assetTypeElement.ValueKind == JsonValueKind.String | ||||||
|  |             ? NormalizeValue(assetTypeElement.GetString()) | ||||||
|  |             : null; | ||||||
|  |  | ||||||
|  |         var normalizedPath = NormalizePath(name); | ||||||
|  |         if (string.IsNullOrEmpty(normalizedPath)) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         DotNetLibraryAssetKind kind; | ||||||
|  |         if (assetType is null || string.Equals(assetType, "runtime", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |         { | ||||||
|  |             if (!IsManagedAssembly(normalizedPath)) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             kind = DotNetLibraryAssetKind.Runtime; | ||||||
|  |         } | ||||||
|  |         else if (string.Equals(assetType, "native", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |         { | ||||||
|  |             kind = DotNetLibraryAssetKind.Native; | ||||||
|  |         } | ||||||
|  |         else | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         string? assemblyVersion = null; | ||||||
|  |         string? fileVersion = null; | ||||||
|  |  | ||||||
|  |         if (kind == DotNetLibraryAssetKind.Runtime && | ||||||
|  |             element.TryGetProperty("assemblyVersion", out var assemblyVersionElement) && | ||||||
|  |             assemblyVersionElement.ValueKind == JsonValueKind.String) | ||||||
|  |         { | ||||||
|  |             assemblyVersion = NormalizeValue(assemblyVersionElement.GetString()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (kind == DotNetLibraryAssetKind.Runtime && | ||||||
|  |             element.TryGetProperty("fileVersion", out var fileVersionElement) && | ||||||
|  |             fileVersionElement.ValueKind == JsonValueKind.String) | ||||||
|  |         { | ||||||
|  |             fileVersion = NormalizeValue(fileVersionElement.GetString()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         string? runtimeIdentifier = rid; | ||||||
|  |         if (element.TryGetProperty("rid", out var ridElement) && ridElement.ValueKind == JsonValueKind.String) | ||||||
|  |         { | ||||||
|  |             runtimeIdentifier = NormalizeValue(ridElement.GetString()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         asset = new DotNetLibraryAsset( | ||||||
|  |             RelativePath: normalizedPath, | ||||||
|  |             TargetFramework: NormalizeValue(tfm), | ||||||
|  |             RuntimeIdentifier: NormalizeValue(runtimeIdentifier), | ||||||
|  |             AssemblyVersion: assemblyVersion, | ||||||
|  |             FileVersion: fileVersion, | ||||||
|  |             Kind: kind); | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string NormalizePath(string value) | ||||||
|  |     { | ||||||
|  |         var normalized = NormalizeValue(value); | ||||||
|  |         if (string.IsNullOrEmpty(normalized)) | ||||||
|  |         { | ||||||
|  |             return string.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return normalized.Replace('\\', '/'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool IsManagedAssembly(string path) | ||||||
|  |         => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || | ||||||
|  |            path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); | ||||||
|  |  | ||||||
|  |     private static string? NormalizeValue(string? value) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return value.Trim(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -3,8 +3,8 @@ | |||||||
| | Seq | ID | Status | Depends on | Description | Exit Criteria | | | Seq | ID | Status | Depends on | Description | Exit Criteria | | ||||||
| |-----|----|--------|------------|-------------|---------------| | |-----|----|--------|------------|-------------|---------------| | ||||||
| | 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. | | | 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 | DONE (2025-10-22) | 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 | DONE (2025-10-22) | 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. | | ||||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308D | TODO | SCANNER-ANALYZERS-LANG-10-307D | Determinism fixtures + benchmark harness; compare to competitor scanners for accuracy/perf. | Fixtures in `Fixtures/lang/dotnet/`; determinism CI guard; benchmark demonstrates lower duplication + faster runtime. | | | 5 | SCANNER-ANALYZERS-LANG-10-308D | TODO | SCANNER-ANALYZERS-LANG-10-307D | Determinism fixtures + benchmark harness; compare to competitor scanners for accuracy/perf. | Fixtures in `Fixtures/lang/dotnet/`; determinism CI guard; benchmark demonstrates lower duplication + faster runtime. | | ||||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309D | TODO | SCANNER-ANALYZERS-LANG-10-308D | Package plug-in (manifest, DI registration) and update Offline Kit instructions. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. | | | 6 | SCANNER-ANALYZERS-LANG-10-309D | TODO | SCANNER-ANALYZERS-LANG-10-308D | Package plug-in (manifest, DI registration) and update Offline Kit instructions. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. | | ||||||
|   | |||||||
| @@ -0,0 +1,5 @@ | |||||||
|  | MZfakebinaryheader | ||||||
|  | Go build ID: "random-go-build-id" | ||||||
|  | ....gopclntab.... | ||||||
|  | runtime.buildVersion=go1.22.8 | ||||||
|  | padding0000000000000000000000000000000000000000000000000000000000000000 | ||||||
| @@ -0,0 +1,30 @@ | |||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "analyzerId": "golang", | ||||||
|  |     "componentKey": "golang::bin::sha256:80f528c90b72a4c4cc3fa078501154e4f2a3f49faea3ec380112d61740bde4c3", | ||||||
|  |     "name": "app", | ||||||
|  |     "type": "bin", | ||||||
|  |     "usedByEntrypoint": false, | ||||||
|  |     "metadata": { | ||||||
|  |       "binary.sha256": "80f528c90b72a4c4cc3fa078501154e4f2a3f49faea3ec380112d61740bde4c3", | ||||||
|  |       "binaryPath": "app", | ||||||
|  |       "go.version.hint": "go1.22.8", | ||||||
|  |       "languageHint": "golang", | ||||||
|  |       "provenance": "binary" | ||||||
|  |     }, | ||||||
|  |     "evidence": [ | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "binary", | ||||||
|  |         "locator": "app", | ||||||
|  |         "sha256": "80f528c90b72a4c4cc3fa078501154e4f2a3f49faea3ec380112d61740bde4c3" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "metadata", | ||||||
|  |         "source": "go.heuristic", | ||||||
|  |         "locator": "classification", | ||||||
|  |         "value": "build-id" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   } | ||||||
|  | ] | ||||||
| @@ -44,4 +44,23 @@ public sealed class GoLanguageAnalyzerTests | |||||||
|             analyzers, |             analyzers, | ||||||
|             cancellationToken); |             cancellationToken); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task StrippedBinaryFallsBackToHeuristicBinHashAsync() | ||||||
|  |     { | ||||||
|  |         var cancellationToken = TestContext.Current.CancellationToken; | ||||||
|  |         var fixturePath = TestPaths.ResolveFixture("lang", "go", "stripped"); | ||||||
|  |         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||||
|  |  | ||||||
|  |         var analyzers = new ILanguageAnalyzer[] | ||||||
|  |         { | ||||||
|  |             new GoLanguageAnalyzer(), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||||
|  |             fixturePath, | ||||||
|  |             goldenPath, | ||||||
|  |             analyzers, | ||||||
|  |             cancellationToken); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -21,18 +21,31 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer | |||||||
|         var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath)); |         var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath)); | ||||||
|         candidatePaths.Sort(StringComparer.Ordinal); |         candidatePaths.Sort(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |         var fallbackBinaries = new List<GoStrippedBinaryClassification>(); | ||||||
|  |  | ||||||
|         foreach (var absolutePath in candidatePaths) |         foreach (var absolutePath in candidatePaths) | ||||||
|         { |         { | ||||||
|             cancellationToken.ThrowIfCancellationRequested(); |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|             if (!GoBuildInfoProvider.TryGetBuildInfo(absolutePath, out var buildInfo) || buildInfo is null) |             if (!GoBuildInfoProvider.TryGetBuildInfo(absolutePath, out var buildInfo) || buildInfo is null) | ||||||
|             { |             { | ||||||
|  |                 if (GoBinaryScanner.TryClassifyStrippedBinary(absolutePath, out var classification)) | ||||||
|  |                 { | ||||||
|  |                     fallbackBinaries.Add(classification); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 continue; |                 continue; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             EmitComponents(buildInfo, context, writer); |             EmitComponents(buildInfo, context, writer); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         foreach (var fallback in fallbackBinaries) | ||||||
|  |         { | ||||||
|  |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |             EmitFallbackComponent(fallback, context, writer); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         return ValueTask.CompletedTask; |         return ValueTask.CompletedTask; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -144,6 +157,84 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer | |||||||
|         return entries; |         return entries; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private void EmitFallbackComponent(GoStrippedBinaryClassification strippedBinary, LanguageAnalyzerContext context, LanguageComponentWriter writer) | ||||||
|  |     { | ||||||
|  |         var relativePath = context.GetRelativePath(strippedBinary.AbsolutePath); | ||||||
|  |         var normalizedRelative = string.IsNullOrEmpty(relativePath) ? "." : relativePath; | ||||||
|  |         var usedByEntrypoint = context.UsageHints.IsPathUsed(strippedBinary.AbsolutePath); | ||||||
|  |  | ||||||
|  |         var binaryHash = ComputeBinaryHash(strippedBinary.AbsolutePath); | ||||||
|  |  | ||||||
|  |         var metadata = new List<KeyValuePair<string, string?>> | ||||||
|  |         { | ||||||
|  |             new("binaryPath", normalizedRelative), | ||||||
|  |             new("languageHint", "golang"), | ||||||
|  |             new("provenance", "binary"), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrEmpty(binaryHash)) | ||||||
|  |         { | ||||||
|  |             metadata.Add(new KeyValuePair<string, string?>("binary.sha256", binaryHash)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrEmpty(strippedBinary.GoVersionHint)) | ||||||
|  |         { | ||||||
|  |             metadata.Add(new KeyValuePair<string, string?>("go.version.hint", strippedBinary.GoVersionHint)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||||
|  |  | ||||||
|  |         var evidence = new List<LanguageComponentEvidence> | ||||||
|  |         { | ||||||
|  |             new( | ||||||
|  |                 LanguageEvidenceKind.File, | ||||||
|  |                 "binary", | ||||||
|  |                 normalizedRelative, | ||||||
|  |                 null, | ||||||
|  |                 string.IsNullOrEmpty(binaryHash) ? null : binaryHash), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         var detectionSource = strippedBinary.Indicator switch | ||||||
|  |         { | ||||||
|  |             GoStrippedBinaryIndicator.BuildId => "build-id", | ||||||
|  |             GoStrippedBinaryIndicator.GoRuntimeMarkers => "runtime-markers", | ||||||
|  |             _ => null, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrEmpty(detectionSource)) | ||||||
|  |         { | ||||||
|  |             evidence.Add(new LanguageComponentEvidence( | ||||||
|  |                 LanguageEvidenceKind.Metadata, | ||||||
|  |                 "go.heuristic", | ||||||
|  |                 "classification", | ||||||
|  |                 detectionSource, | ||||||
|  |                 null)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         evidence.Sort(static (left, right) => string.CompareOrdinal(left.ComparisonKey, right.ComparisonKey)); | ||||||
|  |  | ||||||
|  |         var componentName = Path.GetFileName(strippedBinary.AbsolutePath); | ||||||
|  |         if (string.IsNullOrWhiteSpace(componentName)) | ||||||
|  |         { | ||||||
|  |             componentName = "golang-binary"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var componentKey = string.IsNullOrEmpty(binaryHash) | ||||||
|  |             ? $"golang::bin::{normalizedRelative}" | ||||||
|  |             : $"golang::bin::sha256:{binaryHash}"; | ||||||
|  |  | ||||||
|  |         writer.AddFromExplicitKey( | ||||||
|  |             analyzerId: Id, | ||||||
|  |             componentKey: componentKey, | ||||||
|  |             purl: null, | ||||||
|  |             name: componentName, | ||||||
|  |             version: null, | ||||||
|  |             type: "bin", | ||||||
|  |             metadata: metadata, | ||||||
|  |             evidence: evidence, | ||||||
|  |             usedByEntrypoint: usedByEntrypoint); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private static IEnumerable<LanguageComponentEvidence> BuildEvidence(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath, LanguageAnalyzerContext context, ref string? binaryHash) |     private static IEnumerable<LanguageComponentEvidence> BuildEvidence(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath, LanguageAnalyzerContext context, ref string? binaryHash) | ||||||
|     { |     { | ||||||
|         var evidence = new List<LanguageComponentEvidence> |         var evidence = new List<LanguageComponentEvidence> | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
|  | using System.Buffers; | ||||||
| using System.IO; | using System.IO; | ||||||
|  | using System.Text; | ||||||
|  |  | ||||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||||
|  |  | ||||||
| @@ -11,6 +13,10 @@ internal static class GoBinaryScanner | |||||||
|         0xFF, (byte)' ', (byte)'G', (byte)'o', (byte)' ', (byte)'b', (byte)'u', (byte)'i', (byte)'l', (byte)'d', (byte)'i', (byte)'n', (byte)'f', (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)':' | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|  |     private static readonly ReadOnlyMemory<byte> BuildIdMarker = Encoding.ASCII.GetBytes("Go build ID:"); | ||||||
|  |     private static readonly ReadOnlyMemory<byte> GoPclnTabMarker = Encoding.ASCII.GetBytes(".gopclntab"); | ||||||
|  |     private static readonly ReadOnlyMemory<byte> GoVersionPrefix = Encoding.ASCII.GetBytes("go1."); | ||||||
|  |  | ||||||
|     public static IEnumerable<string> EnumerateCandidateFiles(string rootPath) |     public static IEnumerable<string> EnumerateCandidateFiles(string rootPath) | ||||||
|     { |     { | ||||||
|         var enumeration = new EnumerationOptions |         var enumeration = new EnumerationOptions | ||||||
| @@ -60,4 +66,151 @@ internal static class GoBinaryScanner | |||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public static bool TryClassifyStrippedBinary(string filePath, out GoStrippedBinaryClassification classification) | ||||||
|  |     { | ||||||
|  |         classification = default; | ||||||
|  |  | ||||||
|  |         FileInfo fileInfo; | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             fileInfo = new FileInfo(filePath); | ||||||
|  |             if (!fileInfo.Exists) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         catch (IOException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         catch (UnauthorizedAccessException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         catch (System.Security.SecurityException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var length = fileInfo.Length; | ||||||
|  |         if (length < 128) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const int WindowSize = 128 * 1024; | ||||||
|  |         var readSize = (int)Math.Min(length, WindowSize); | ||||||
|  |         var buffer = ArrayPool<byte>.Shared.Rent(readSize); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|  |  | ||||||
|  |             var headRead = stream.Read(buffer, 0, readSize); | ||||||
|  |             if (headRead <= 0) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var headSpan = new ReadOnlySpan<byte>(buffer, 0, headRead); | ||||||
|  |             var hasBuildId = headSpan.IndexOf(BuildIdMarker.Span) >= 0; | ||||||
|  |             var hasPcln = headSpan.IndexOf(GoPclnTabMarker.Span) >= 0; | ||||||
|  |             var goVersion = ExtractGoVersion(headSpan); | ||||||
|  |  | ||||||
|  |             if (length > headRead) | ||||||
|  |             { | ||||||
|  |                 var tailSize = Math.Min(readSize, (int)length); | ||||||
|  |                 if (tailSize > 0) | ||||||
|  |                 { | ||||||
|  |                     stream.Seek(-tailSize, SeekOrigin.End); | ||||||
|  |                     var tailRead = stream.Read(buffer, 0, tailSize); | ||||||
|  |                     if (tailRead > 0) | ||||||
|  |                     { | ||||||
|  |                         var tailSpan = new ReadOnlySpan<byte>(buffer, 0, tailRead); | ||||||
|  |                         hasBuildId |= tailSpan.IndexOf(BuildIdMarker.Span) >= 0; | ||||||
|  |                         hasPcln |= tailSpan.IndexOf(GoPclnTabMarker.Span) >= 0; | ||||||
|  |                         goVersion ??= ExtractGoVersion(tailSpan); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (hasBuildId) | ||||||
|  |             { | ||||||
|  |                 classification = new GoStrippedBinaryClassification( | ||||||
|  |                     filePath, | ||||||
|  |                     GoStrippedBinaryIndicator.BuildId, | ||||||
|  |                     goVersion); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (hasPcln && !string.IsNullOrEmpty(goVersion)) | ||||||
|  |             { | ||||||
|  |                 classification = new GoStrippedBinaryClassification( | ||||||
|  |                     filePath, | ||||||
|  |                     GoStrippedBinaryIndicator.GoRuntimeMarkers, | ||||||
|  |                     goVersion); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             Array.Clear(buffer, 0, readSize); | ||||||
|  |             ArrayPool<byte>.Shared.Return(buffer); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? ExtractGoVersion(ReadOnlySpan<byte> data) | ||||||
|  |     { | ||||||
|  |         var prefix = GoVersionPrefix.Span; | ||||||
|  |         var span = data; | ||||||
|  |  | ||||||
|  |         while (!span.IsEmpty) | ||||||
|  |         { | ||||||
|  |             var index = span.IndexOf(prefix); | ||||||
|  |             if (index < 0) | ||||||
|  |             { | ||||||
|  |                 return null; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var absoluteIndex = data.Length - span.Length + index; | ||||||
|  |  | ||||||
|  |             if (absoluteIndex > 0) | ||||||
|  |             { | ||||||
|  |                 var previous = (char)data[absoluteIndex - 1]; | ||||||
|  |                 if (char.IsLetterOrDigit(previous)) | ||||||
|  |                 { | ||||||
|  |                     span = span[(index + 1)..]; | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var start = absoluteIndex; | ||||||
|  |             var end = start + prefix.Length; | ||||||
|  |  | ||||||
|  |             while (end < data.Length && IsVersionCharacter((char)data[end])) | ||||||
|  |             { | ||||||
|  |                 end++; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (end - start <= prefix.Length) | ||||||
|  |             { | ||||||
|  |                 span = span[(index + 1)..]; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var candidate = data[start..end]; | ||||||
|  |             return Encoding.ASCII.GetString(candidate); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool IsVersionCharacter(char value) | ||||||
|  |         => (value >= '0' && value <= '9') | ||||||
|  |            || (value >= 'a' && value <= 'z') | ||||||
|  |            || (value >= 'A' && value <= 'Z') | ||||||
|  |            || value is '.' or '-' or '+' or '_'; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||||
|  |  | ||||||
|  | internal readonly record struct GoStrippedBinaryClassification( | ||||||
|  |     string AbsolutePath, | ||||||
|  |     GoStrippedBinaryIndicator Indicator, | ||||||
|  |     string? GoVersionHint); | ||||||
|  |  | ||||||
|  | internal enum GoStrippedBinaryIndicator | ||||||
|  | { | ||||||
|  |     None = 0, | ||||||
|  |     BuildId, | ||||||
|  |     GoRuntimeMarkers, | ||||||
|  | } | ||||||
| @@ -4,7 +4,8 @@ | |||||||
| |-----|----|--------|------------|-------------|---------------| | |-----|----|--------|------------|-------------|---------------| | ||||||
| | 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. | | | 1 | SCANNER-ANALYZERS-LANG-10-304A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence. | Build info extracted across Go 1.18–1.23 fixtures; evidence includes VCS, module path, and build settings. | | ||||||
| | 2 | SCANNER-ANALYZERS-LANG-10-304B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304A | Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries. | DWARF reader supplies commit hash for ≥95 % fixtures; cache reduces duplicated IO by ≥70 %. | | | 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 | DONE (2025-10-22) | 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. | | ||||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309G | TODO | SCANNER-ANALYZERS-LANG-10-308G | Package plug-in manifest + Offline Kit notes; ensure Worker DI registration. | Manifest copied; Worker loads analyzer; Offline Kit docs updated with Go analyzer presence. | | | 6 | SCANNER-ANALYZERS-LANG-10-309G | TODO | SCANNER-ANALYZERS-LANG-10-308G | Package plug-in manifest + Offline Kit notes; ensure Worker DI registration. | Manifest copied; Worker loads analyzer; Offline Kit docs updated with Go analyzer presence. | | ||||||
|  | | 7 | SCANNER-ANALYZERS-LANG-10-304D | TODO | SCANNER-ANALYZERS-LANG-10-304C | Emit telemetry counters for stripped-binary heuristics and document metrics wiring. | New `scanner_analyzer_golang_heuristic_total` counter recorded; docs updated with offline aggregation notes. | | ||||||
|   | |||||||
| @@ -0,0 +1,650 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Security.Cryptography; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||||
|  |  | ||||||
|  | internal static class RustAnalyzerCollector | ||||||
|  | { | ||||||
|  |     public static RustAnalyzerCollection Collect(LanguageAnalyzerContext context, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(context); | ||||||
|  |  | ||||||
|  |         var collector = new Collector(context); | ||||||
|  |         collector.Execute(cancellationToken); | ||||||
|  |         return collector.Build(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class Collector | ||||||
|  |     { | ||||||
|  |         private static readonly EnumerationOptions LockEnumeration = new() | ||||||
|  |         { | ||||||
|  |             MatchCasing = MatchCasing.CaseSensitive, | ||||||
|  |             IgnoreInaccessible = true, | ||||||
|  |             RecurseSubdirectories = true, | ||||||
|  |             AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         private readonly LanguageAnalyzerContext _context; | ||||||
|  |         private readonly Dictionary<RustCrateKey, RustCrateBuilder> _crates = new(); | ||||||
|  |         private readonly Dictionary<string, List<RustCrateBuilder>> _cratesByName = new(StringComparer.Ordinal); | ||||||
|  |         private readonly Dictionary<string, RustHeuristicBuilder> _heuristics = new(StringComparer.Ordinal); | ||||||
|  |         private readonly Dictionary<string, RustBinaryRecord> _binaries = new(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |         public Collector(LanguageAnalyzerContext context) | ||||||
|  |         { | ||||||
|  |             _context = context; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public void Execute(CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             CollectCargoLocks(cancellationToken); | ||||||
|  |             CollectFingerprints(cancellationToken); | ||||||
|  |             CollectBinaries(cancellationToken); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public RustAnalyzerCollection Build() | ||||||
|  |         { | ||||||
|  |             var crateRecords = _crates.Values | ||||||
|  |                 .Select(static builder => builder.Build()) | ||||||
|  |                 .OrderBy(static record => record.ComponentKey, StringComparer.Ordinal) | ||||||
|  |                 .ToImmutableArray(); | ||||||
|  |  | ||||||
|  |             var heuristicRecords = _heuristics.Values | ||||||
|  |                 .Select(static builder => builder.Build()) | ||||||
|  |                 .OrderBy(static record => record.ComponentKey, StringComparer.Ordinal) | ||||||
|  |                 .ToImmutableArray(); | ||||||
|  |  | ||||||
|  |             var fallbackRecords = _binaries.Values | ||||||
|  |                 .Where(static record => !record.HasMatches) | ||||||
|  |                 .Select(BuildFallback) | ||||||
|  |                 .OrderBy(static record => record.ComponentKey, StringComparer.Ordinal) | ||||||
|  |                 .ToImmutableArray(); | ||||||
|  |  | ||||||
|  |             return new RustAnalyzerCollection(crateRecords, heuristicRecords, fallbackRecords); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private void CollectCargoLocks(CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             foreach (var lockPath in Directory.EnumerateFiles(_context.RootPath, "Cargo.lock", LockEnumeration)) | ||||||
|  |             { | ||||||
|  |                 cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |                 var packages = RustCargoLockParser.Parse(lockPath, cancellationToken); | ||||||
|  |                 if (packages.Count == 0) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var relativePath = NormalizeRelative(_context.GetRelativePath(lockPath)); | ||||||
|  |                 foreach (var package in packages) | ||||||
|  |                 { | ||||||
|  |                     var builder = GetOrCreateCrate(package.Name, package.Version); | ||||||
|  |                     builder.ApplyCargoPackage(package, relativePath); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private void CollectFingerprints(CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             var records = RustFingerprintScanner.Scan(_context.RootPath, cancellationToken); | ||||||
|  |             foreach (var record in records) | ||||||
|  |             { | ||||||
|  |                 cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |                 var builder = GetOrCreateCrate(record.Name, record.Version); | ||||||
|  |                 var relative = NormalizeRelative(_context.GetRelativePath(record.AbsolutePath)); | ||||||
|  |                 builder.ApplyFingerprint(record, relative); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private void CollectBinaries(CancellationToken cancellationToken) | ||||||
|  |         { | ||||||
|  |             var binaries = RustBinaryClassifier.Scan(_context.RootPath, cancellationToken); | ||||||
|  |             foreach (var binary in binaries) | ||||||
|  |             { | ||||||
|  |                 cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |                 var relative = NormalizeRelative(_context.GetRelativePath(binary.AbsolutePath)); | ||||||
|  |                 var usage = _context.UsageHints.IsPathUsed(binary.AbsolutePath); | ||||||
|  |                 var hash = binary.ComputeSha256(); | ||||||
|  |  | ||||||
|  |                 if (!_binaries.TryGetValue(relative, out var record)) | ||||||
|  |                 { | ||||||
|  |                     record = new RustBinaryRecord(binary.AbsolutePath, relative, usage, hash); | ||||||
|  |                     _binaries[relative] = record; | ||||||
|  |                 } | ||||||
|  |                 else | ||||||
|  |                 { | ||||||
|  |                     record.MergeUsage(usage); | ||||||
|  |                     record.EnsureHash(hash); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (binary.CrateCandidates.IsDefaultOrEmpty || binary.CrateCandidates.Length == 0) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 foreach (var candidate in binary.CrateCandidates) | ||||||
|  |                 { | ||||||
|  |                     if (string.IsNullOrWhiteSpace(candidate)) | ||||||
|  |                     { | ||||||
|  |                         continue; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var crateBuilder = FindCrateByName(candidate); | ||||||
|  |                     if (crateBuilder is not null) | ||||||
|  |                     { | ||||||
|  |                         crateBuilder.AddBinaryEvidence(relative, record.Hash, usage); | ||||||
|  |                         record.MarkCrateMatch(); | ||||||
|  |                         continue; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var heuristic = GetOrCreateHeuristic(candidate); | ||||||
|  |                     heuristic.AddBinary(relative, record.Hash, usage); | ||||||
|  |                     record.MarkHeuristicMatch(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private RustCrateBuilder GetOrCreateCrate(string name, string? version) | ||||||
|  |         { | ||||||
|  |             var key = new RustCrateKey(name, version); | ||||||
|  |             if (_crates.TryGetValue(key, out var existing)) | ||||||
|  |             { | ||||||
|  |                 existing.EnsureVersion(version); | ||||||
|  |                 return existing; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var builder = new RustCrateBuilder(name, version); | ||||||
|  |             _crates[key] = builder; | ||||||
|  |  | ||||||
|  |             if (!_cratesByName.TryGetValue(builder.Name, out var list)) | ||||||
|  |             { | ||||||
|  |                 list = new List<RustCrateBuilder>(); | ||||||
|  |                 _cratesByName[builder.Name] = list; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             list.Add(builder); | ||||||
|  |             return builder; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private RustCrateBuilder? FindCrateByName(string candidate) | ||||||
|  |         { | ||||||
|  |             var normalized = RustCrateBuilder.NormalizeName(candidate); | ||||||
|  |             if (!_cratesByName.TryGetValue(normalized, out var builders) || builders.Count == 0) | ||||||
|  |             { | ||||||
|  |                 return null; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return builders | ||||||
|  |                 .OrderBy(static builder => builder.Version ?? string.Empty, StringComparer.Ordinal) | ||||||
|  |                 .FirstOrDefault(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private RustHeuristicBuilder GetOrCreateHeuristic(string crateName) | ||||||
|  |         { | ||||||
|  |             var normalized = RustCrateBuilder.NormalizeName(crateName); | ||||||
|  |             if (_heuristics.TryGetValue(normalized, out var existing)) | ||||||
|  |             { | ||||||
|  |                 return existing; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var builder = new RustHeuristicBuilder(normalized); | ||||||
|  |             _heuristics[normalized] = builder; | ||||||
|  |             return builder; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private RustComponentRecord BuildFallback(RustBinaryRecord record) | ||||||
|  |         { | ||||||
|  |             var metadata = new List<KeyValuePair<string, string?>> | ||||||
|  |             { | ||||||
|  |                 new("binary.path", record.RelativePath), | ||||||
|  |                 new("provenance", "binary"), | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrEmpty(record.Hash)) | ||||||
|  |             { | ||||||
|  |                 metadata.Add(new KeyValuePair<string, string?>("binary.sha256", record.Hash)); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||||
|  |  | ||||||
|  |             var evidence = new List<LanguageComponentEvidence> | ||||||
|  |             { | ||||||
|  |                 new( | ||||||
|  |                     LanguageEvidenceKind.File, | ||||||
|  |                     "binary", | ||||||
|  |                     record.RelativePath, | ||||||
|  |                     null, | ||||||
|  |                     string.IsNullOrEmpty(record.Hash) ? null : record.Hash) | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             var componentName = Path.GetFileName(record.RelativePath); | ||||||
|  |             if (string.IsNullOrWhiteSpace(componentName)) | ||||||
|  |             { | ||||||
|  |                 componentName = "binary"; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var key = string.IsNullOrEmpty(record.Hash) | ||||||
|  |                 ? $"bin::{record.RelativePath}" | ||||||
|  |                 : $"bin::sha256:{record.Hash}"; | ||||||
|  |  | ||||||
|  |             return new RustComponentRecord( | ||||||
|  |                 Name: componentName, | ||||||
|  |                 Version: null, | ||||||
|  |                 Type: "bin", | ||||||
|  |                 Purl: null, | ||||||
|  |                 ComponentKey: key, | ||||||
|  |                 Metadata: metadata, | ||||||
|  |                 Evidence: evidence, | ||||||
|  |                 UsedByEntrypoint: record.UsedByEntrypoint); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private static string NormalizeRelative(string relativePath) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(relativePath) || relativePath == ".") | ||||||
|  |             { | ||||||
|  |                 return "."; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return relativePath.Replace('\\', '/'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal sealed record RustAnalyzerCollection( | ||||||
|  |     ImmutableArray<RustComponentRecord> Crates, | ||||||
|  |     ImmutableArray<RustComponentRecord> Heuristics, | ||||||
|  |     ImmutableArray<RustComponentRecord> Fallbacks); | ||||||
|  |  | ||||||
|  | internal sealed record RustComponentRecord( | ||||||
|  |     string Name, | ||||||
|  |     string? Version, | ||||||
|  |     string Type, | ||||||
|  |     string? Purl, | ||||||
|  |     string ComponentKey, | ||||||
|  |     IReadOnlyList<KeyValuePair<string, string?>> Metadata, | ||||||
|  |     IReadOnlyCollection<LanguageComponentEvidence> Evidence, | ||||||
|  |     bool UsedByEntrypoint); | ||||||
|  |  | ||||||
|  | internal sealed class RustCrateBuilder | ||||||
|  | { | ||||||
|  |     private readonly SortedDictionary<string, string?> _metadata = new(StringComparer.Ordinal); | ||||||
|  |     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); | ||||||
|  |     private readonly SortedSet<string> _binaryPaths = new(StringComparer.Ordinal); | ||||||
|  |     private readonly SortedSet<string> _binaryHashes = new(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |     private string? _version; | ||||||
|  |     private string? _source; | ||||||
|  |     private string? _checksum; | ||||||
|  |     private bool _usedByEntrypoint; | ||||||
|  |  | ||||||
|  |     public RustCrateBuilder(string name, string? version) | ||||||
|  |     { | ||||||
|  |         Name = NormalizeName(name); | ||||||
|  |         EnsureVersion(version); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public string Name { get; } | ||||||
|  |  | ||||||
|  |     public string? Version => _version; | ||||||
|  |  | ||||||
|  |     public static string NormalizeName(string value) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             return string.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return value.Trim(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void EnsureVersion(string? version) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(version)) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _version ??= version.Trim(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void ApplyCargoPackage(RustCargoPackage package, string relativePath) | ||||||
|  |     { | ||||||
|  |         EnsureVersion(package.Version); | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(package.Source)) | ||||||
|  |         { | ||||||
|  |             _source ??= package.Source.Trim(); | ||||||
|  |             _metadata["source"] = _source; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(package.Checksum)) | ||||||
|  |         { | ||||||
|  |             _checksum ??= package.Checksum.Trim(); | ||||||
|  |             _metadata["checksum"] = _checksum; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _metadata["cargo.lock.path"] = relativePath; | ||||||
|  |  | ||||||
|  |         _evidence.Add(new LanguageComponentEvidence( | ||||||
|  |             LanguageEvidenceKind.File, | ||||||
|  |             "cargo.lock", | ||||||
|  |             relativePath, | ||||||
|  |             $"{package.Name} {package.Version}", | ||||||
|  |             string.IsNullOrWhiteSpace(package.Checksum) ? null : package.Checksum)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void ApplyFingerprint(RustFingerprintRecord record, string relativePath) | ||||||
|  |     { | ||||||
|  |         EnsureVersion(record.Version); | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(record.Source)) | ||||||
|  |         { | ||||||
|  |             _source ??= record.Source.Trim(); | ||||||
|  |             _metadata["source"] = _source; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AddMetadataIfEmpty("fingerprint.profile", record.Profile); | ||||||
|  |         AddMetadataIfEmpty("fingerprint.targetKind", record.TargetKind); | ||||||
|  |  | ||||||
|  |         _evidence.Add(new LanguageComponentEvidence( | ||||||
|  |             LanguageEvidenceKind.File, | ||||||
|  |             "cargo.fingerprint", | ||||||
|  |             relativePath, | ||||||
|  |             record.TargetKind ?? record.Profile ?? "fingerprint", | ||||||
|  |             null)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void AddBinaryEvidence(string relativePath, string? hash, bool usedByEntrypoint) | ||||||
|  |     { | ||||||
|  |         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||||
|  |         { | ||||||
|  |             _binaryPaths.Add(relativePath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(hash)) | ||||||
|  |         { | ||||||
|  |             _binaryHashes.Add(hash); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (usedByEntrypoint) | ||||||
|  |         { | ||||||
|  |             _usedByEntrypoint = true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||||
|  |         { | ||||||
|  |             _evidence.Add(new LanguageComponentEvidence( | ||||||
|  |                 LanguageEvidenceKind.File, | ||||||
|  |                 "binary", | ||||||
|  |                 relativePath, | ||||||
|  |                 null, | ||||||
|  |                 string.IsNullOrWhiteSpace(hash) ? null : hash)); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public RustComponentRecord Build() | ||||||
|  |     { | ||||||
|  |         if (_binaryPaths.Count > 0) | ||||||
|  |         { | ||||||
|  |             _metadata["binary.paths"] = string.Join(';', _binaryPaths); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (_binaryHashes.Count > 0) | ||||||
|  |         { | ||||||
|  |             _metadata["binary.sha256"] = string.Join(';', _binaryHashes); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var metadata = _metadata | ||||||
|  |             .Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value)) | ||||||
|  |             .OrderBy(static pair => pair.Key, StringComparer.Ordinal) | ||||||
|  |             .ToList(); | ||||||
|  |  | ||||||
|  |         var evidence = _evidence | ||||||
|  |             .OrderBy(static item => item.ComparisonKey, StringComparer.Ordinal) | ||||||
|  |             .ToImmutableArray(); | ||||||
|  |  | ||||||
|  |         var purl = BuildPurl(Name, _version); | ||||||
|  |         var componentKey = string.IsNullOrEmpty(purl) | ||||||
|  |             ? $"cargo::{Name}::{_version ?? "unknown"}" | ||||||
|  |             : $"purl::{purl}"; | ||||||
|  |  | ||||||
|  |         return new RustComponentRecord( | ||||||
|  |             Name: Name, | ||||||
|  |             Version: _version, | ||||||
|  |             Type: "cargo", | ||||||
|  |             Purl: purl, | ||||||
|  |             ComponentKey: componentKey, | ||||||
|  |             Metadata: metadata, | ||||||
|  |             Evidence: evidence, | ||||||
|  |             UsedByEntrypoint: _usedByEntrypoint); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void AddMetadataIfEmpty(string key, string? value) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (_metadata.ContainsKey(key)) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _metadata[key] = value.Trim(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? BuildPurl(string name, string? version) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(name)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var escapedName = Uri.EscapeDataString(name.Trim()); | ||||||
|  |         if (string.IsNullOrWhiteSpace(version)) | ||||||
|  |         { | ||||||
|  |             return $"pkg:cargo/{escapedName}"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var escapedVersion = Uri.EscapeDataString(version.Trim()); | ||||||
|  |         return $"pkg:cargo/{escapedName}@{escapedVersion}"; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal sealed class RustHeuristicBuilder | ||||||
|  | { | ||||||
|  |     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); | ||||||
|  |     private readonly SortedSet<string> _binaryPaths = new(StringComparer.Ordinal); | ||||||
|  |     private readonly SortedSet<string> _binaryHashes = new(StringComparer.Ordinal); | ||||||
|  |     private bool _usedByEntrypoint; | ||||||
|  |  | ||||||
|  |     public RustHeuristicBuilder(string crateName) | ||||||
|  |     { | ||||||
|  |         CrateName = RustCrateBuilder.NormalizeName(crateName); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public string CrateName { get; } | ||||||
|  |  | ||||||
|  |     public void AddBinary(string relativePath, string? hash, bool usedByEntrypoint) | ||||||
|  |     { | ||||||
|  |         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||||
|  |         { | ||||||
|  |             _binaryPaths.Add(relativePath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(hash)) | ||||||
|  |         { | ||||||
|  |             _binaryHashes.Add(hash); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (usedByEntrypoint) | ||||||
|  |         { | ||||||
|  |             _usedByEntrypoint = true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||||
|  |         { | ||||||
|  |             _evidence.Add(new LanguageComponentEvidence( | ||||||
|  |                 LanguageEvidenceKind.Derived, | ||||||
|  |                 "rust.heuristic", | ||||||
|  |                 relativePath, | ||||||
|  |                 CrateName, | ||||||
|  |                 string.IsNullOrWhiteSpace(hash) ? null : hash)); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public RustComponentRecord Build() | ||||||
|  |     { | ||||||
|  |         var metadata = new List<KeyValuePair<string, string?>> | ||||||
|  |         { | ||||||
|  |             new("crate", CrateName), | ||||||
|  |             new("provenance", "heuristic"), | ||||||
|  |             new("binary.paths", string.Join(';', _binaryPaths)), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if (_binaryHashes.Count > 0) | ||||||
|  |         { | ||||||
|  |             metadata.Add(new KeyValuePair<string, string?>("binary.sha256", string.Join(';', _binaryHashes))); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||||
|  |  | ||||||
|  |         var evidence = _evidence | ||||||
|  |             .OrderBy(static item => item.ComparisonKey, StringComparer.Ordinal) | ||||||
|  |             .ToImmutableArray(); | ||||||
|  |  | ||||||
|  |         var suffix = string.Join("|", _binaryPaths); | ||||||
|  |         var componentKey = $"rust::heuristic::{CrateName}::{suffix}"; | ||||||
|  |  | ||||||
|  |         return new RustComponentRecord( | ||||||
|  |             Name: CrateName, | ||||||
|  |             Version: null, | ||||||
|  |             Type: "cargo", | ||||||
|  |             Purl: null, | ||||||
|  |             ComponentKey: componentKey, | ||||||
|  |             Metadata: metadata, | ||||||
|  |             Evidence: evidence, | ||||||
|  |             UsedByEntrypoint: _usedByEntrypoint); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal sealed class RustBinaryRecord | ||||||
|  | { | ||||||
|  |     private string? _hash; | ||||||
|  |  | ||||||
|  |     public RustBinaryRecord(string absolutePath, string relativePath, bool usedByEntrypoint, string? hash) | ||||||
|  |     { | ||||||
|  |         AbsolutePath = absolutePath ?? throw new ArgumentNullException(nameof(absolutePath)); | ||||||
|  |         RelativePath = string.IsNullOrWhiteSpace(relativePath) ? "." : relativePath; | ||||||
|  |         UsedByEntrypoint = usedByEntrypoint; | ||||||
|  |         _hash = string.IsNullOrWhiteSpace(hash) ? null : hash; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public string AbsolutePath { get; } | ||||||
|  |  | ||||||
|  |     public string RelativePath { get; } | ||||||
|  |  | ||||||
|  |     public bool UsedByEntrypoint { get; private set; } | ||||||
|  |  | ||||||
|  |     public bool HasMatches => HasCrateMatch || HasHeuristicMatch; | ||||||
|  |  | ||||||
|  |     public bool HasCrateMatch { get; private set; } | ||||||
|  |  | ||||||
|  |     public bool HasHeuristicMatch { get; private set; } | ||||||
|  |  | ||||||
|  |     public string? Hash => _hash; | ||||||
|  |  | ||||||
|  |     public void MarkCrateMatch() => HasCrateMatch = true; | ||||||
|  |  | ||||||
|  |     public void MarkHeuristicMatch() => HasHeuristicMatch = true; | ||||||
|  |  | ||||||
|  |     public void MergeUsage(bool used) | ||||||
|  |     { | ||||||
|  |         if (used) | ||||||
|  |         { | ||||||
|  |             UsedByEntrypoint = true; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public void EnsureHash(string? hash) | ||||||
|  |     { | ||||||
|  |         if (!string.IsNullOrWhiteSpace(hash)) | ||||||
|  |         { | ||||||
|  |             _hash ??= hash; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (_hash is null) | ||||||
|  |         { | ||||||
|  |             _hash = ComputeHashSafely(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private string? ComputeHashSafely() | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             using var stream = new FileStream(AbsolutePath, 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; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal readonly record struct RustCrateKey | ||||||
|  | { | ||||||
|  |     public RustCrateKey(string name, string? version) | ||||||
|  |     { | ||||||
|  |         Name = RustCrateBuilder.NormalizeName(name); | ||||||
|  |         Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public string Name { get; } | ||||||
|  |  | ||||||
|  |     public string? Version { get; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal 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(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,250 @@ | |||||||
|  | using System.Buffers; | ||||||
|  | using System.Collections.Immutable; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Security.Cryptography; | ||||||
|  | using System.Text; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||||
|  |  | ||||||
|  | internal static class RustBinaryClassifier | ||||||
|  | { | ||||||
|  |     private static readonly ReadOnlyMemory<byte> ElfMagic = new byte[] { 0x7F, (byte)'E', (byte)'L', (byte)'F' }; | ||||||
|  |     private static readonly ReadOnlyMemory<byte> SymbolPrefix = new byte[] { (byte)'_', (byte)'Z', (byte)'N' }; | ||||||
|  |  | ||||||
|  |     private const int ChunkSize = 64 * 1024; | ||||||
|  |     private const int OverlapSize = 48; | ||||||
|  |     private const long MaxBinarySize = 128L * 1024L * 1024L; | ||||||
|  |  | ||||||
|  |     private static readonly HashSet<string> StandardCrates = new(StringComparer.Ordinal) | ||||||
|  |     { | ||||||
|  |         "core", | ||||||
|  |         "alloc", | ||||||
|  |         "std", | ||||||
|  |         "panic_unwind", | ||||||
|  |         "panic_abort", | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     private static readonly EnumerationOptions Enumeration = new() | ||||||
|  |     { | ||||||
|  |         MatchCasing = MatchCasing.CaseSensitive, | ||||||
|  |         IgnoreInaccessible = true, | ||||||
|  |         RecurseSubdirectories = true, | ||||||
|  |         AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     public static IReadOnlyList<RustBinaryInfo> Scan(string rootPath, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(rootPath)) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentException("Root path is required", nameof(rootPath)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var binaries = new List<RustBinaryInfo>(); | ||||||
|  |         foreach (var path in Directory.EnumerateFiles(rootPath, "*", Enumeration)) | ||||||
|  |         { | ||||||
|  |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |             if (!IsEligibleBinary(path)) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var candidates = ExtractCrateNames(path, cancellationToken); | ||||||
|  |             binaries.Add(new RustBinaryInfo(path, candidates)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return binaries; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool IsEligibleBinary(string path) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var info = new FileInfo(path); | ||||||
|  |             if (!info.Exists || info.Length == 0 || info.Length > MaxBinarySize) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             using var stream = info.OpenRead(); | ||||||
|  |             Span<byte> buffer = stackalloc byte[4]; | ||||||
|  |             var read = stream.Read(buffer); | ||||||
|  |             if (read != 4) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return buffer.SequenceEqual(ElfMagic.Span); | ||||||
|  |         } | ||||||
|  |         catch (IOException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         catch (UnauthorizedAccessException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static ImmutableArray<string> ExtractCrateNames(string path, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         var names = new HashSet<string>(StringComparer.Ordinal); | ||||||
|  |         var buffer = ArrayPool<byte>.Shared.Rent(ChunkSize + OverlapSize); | ||||||
|  |         var overlap = new byte[OverlapSize]; | ||||||
|  |         var overlapLength = 0; | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|  |             while (true) | ||||||
|  |             { | ||||||
|  |                 cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |                 // Copy previous overlap to buffer prefix. | ||||||
|  |                 if (overlapLength > 0) | ||||||
|  |                 { | ||||||
|  |                     Array.Copy(overlap, 0, buffer, 0, overlapLength); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var read = stream.Read(buffer, overlapLength, ChunkSize); | ||||||
|  |                 if (read <= 0) | ||||||
|  |                 { | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var span = new ReadOnlySpan<byte>(buffer, 0, overlapLength + read); | ||||||
|  |                 ScanForSymbols(span, names); | ||||||
|  |  | ||||||
|  |                 overlapLength = Math.Min(OverlapSize, span.Length); | ||||||
|  |                 if (overlapLength > 0) | ||||||
|  |                 { | ||||||
|  |                     span[^overlapLength..].CopyTo(overlap); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         catch (IOException) | ||||||
|  |         { | ||||||
|  |             return ImmutableArray<string>.Empty; | ||||||
|  |         } | ||||||
|  |         catch (UnauthorizedAccessException) | ||||||
|  |         { | ||||||
|  |             return ImmutableArray<string>.Empty; | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             ArrayPool<byte>.Shared.Return(buffer); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (names.Count == 0) | ||||||
|  |         { | ||||||
|  |             return ImmutableArray<string>.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var ordered = names | ||||||
|  |             .Where(static name => !string.IsNullOrWhiteSpace(name)) | ||||||
|  |             .Select(static name => name.Trim()) | ||||||
|  |             .Where(static name => name.Length > 1) | ||||||
|  |             .Where(name => !StandardCrates.Contains(name)) | ||||||
|  |             .Distinct(StringComparer.Ordinal) | ||||||
|  |             .OrderBy(static name => name, StringComparer.Ordinal) | ||||||
|  |             .ToImmutableArray(); | ||||||
|  |  | ||||||
|  |         return ordered; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static void ScanForSymbols(ReadOnlySpan<byte> span, HashSet<string> names) | ||||||
|  |     { | ||||||
|  |         var prefix = SymbolPrefix.Span; | ||||||
|  |         var index = 0; | ||||||
|  |  | ||||||
|  |         while (index < span.Length) | ||||||
|  |         { | ||||||
|  |             var slice = span[index..]; | ||||||
|  |             var offset = slice.IndexOf(prefix); | ||||||
|  |             if (offset < 0) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             index += offset + prefix.Length; | ||||||
|  |             if (index >= span.Length) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var remaining = span[index..]; | ||||||
|  |             if (!TryParseCrate(remaining, out var crate, out var consumed)) | ||||||
|  |             { | ||||||
|  |                 index += 1; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(crate)) | ||||||
|  |             { | ||||||
|  |                 names.Add(crate); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             index += Math.Max(consumed, 1); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryParseCrate(ReadOnlySpan<byte> span, out string? crate, out int consumed) | ||||||
|  |     { | ||||||
|  |         crate = null; | ||||||
|  |         consumed = 0; | ||||||
|  |  | ||||||
|  |         var i = 0; | ||||||
|  |         var length = 0; | ||||||
|  |  | ||||||
|  |         while (i < span.Length && span[i] is >= (byte)'0' and <= (byte)'9') | ||||||
|  |         { | ||||||
|  |             length = (length * 10) + (span[i] - (byte)'0'); | ||||||
|  |             i++; | ||||||
|  |  | ||||||
|  |             if (length > 256) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (i == 0 || length <= 0 || i + length > span.Length) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         crate = Encoding.ASCII.GetString(span.Slice(i, length)); | ||||||
|  |         consumed = i + length; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal sealed record RustBinaryInfo(string AbsolutePath, ImmutableArray<string> CrateCandidates) | ||||||
|  | { | ||||||
|  |     private string? _sha256; | ||||||
|  |  | ||||||
|  |     public string ComputeSha256() | ||||||
|  |     { | ||||||
|  |         if (_sha256 is not null) | ||||||
|  |         { | ||||||
|  |             return _sha256; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             using var stream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|  |             using var sha = SHA256.Create(); | ||||||
|  |             var hash = sha.ComputeHash(stream); | ||||||
|  |             _sha256 = Convert.ToHexString(hash).ToLowerInvariant(); | ||||||
|  |         } | ||||||
|  |         catch (IOException) | ||||||
|  |         { | ||||||
|  |             _sha256 = string.Empty; | ||||||
|  |         } | ||||||
|  |         catch (UnauthorizedAccessException) | ||||||
|  |         { | ||||||
|  |             _sha256 = string.Empty; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return _sha256 ?? string.Empty; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,298 @@ | |||||||
|  | namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||||
|  |  | ||||||
|  | internal static class RustCargoLockParser | ||||||
|  | { | ||||||
|  |     public static IReadOnlyList<RustCargoPackage> Parse(string path, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(path)) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentException("Lock path is required", nameof(path)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var info = new FileInfo(path); | ||||||
|  |         if (!info.Exists) | ||||||
|  |         { | ||||||
|  |             return Array.Empty<RustCargoPackage>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var packages = new List<RustCargoPackage>(); | ||||||
|  |         using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|  |         using var reader = new StreamReader(stream); | ||||||
|  |  | ||||||
|  |         RustCargoPackageBuilder? builder = null; | ||||||
|  |         string? currentArrayKey = null; | ||||||
|  |         var arrayValues = new List<string>(); | ||||||
|  |  | ||||||
|  |         while (!reader.EndOfStream) | ||||||
|  |         { | ||||||
|  |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |             var line = reader.ReadLine(); | ||||||
|  |             if (line is null) | ||||||
|  |             { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var trimmed = TrimComments(line.AsSpan()); | ||||||
|  |             if (trimmed.Length == 0) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (IsPackageHeader(trimmed)) | ||||||
|  |             { | ||||||
|  |                 FlushCurrent(builder, packages); | ||||||
|  |                 builder = new RustCargoPackageBuilder(); | ||||||
|  |                 currentArrayKey = null; | ||||||
|  |                 arrayValues.Clear(); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (builder is null) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (currentArrayKey is not null) | ||||||
|  |             { | ||||||
|  |                 if (trimmed[0] == ']') | ||||||
|  |                 { | ||||||
|  |                     builder.SetArray(currentArrayKey, arrayValues); | ||||||
|  |                     currentArrayKey = null; | ||||||
|  |                     arrayValues.Clear(); | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var value = ExtractString(trimmed); | ||||||
|  |                 if (!string.IsNullOrEmpty(value)) | ||||||
|  |                 { | ||||||
|  |                     arrayValues.Add(value); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (trimmed[0] == '[') | ||||||
|  |             { | ||||||
|  |                 // Entering a new table; finish any pending package and skip section. | ||||||
|  |                 FlushCurrent(builder, packages); | ||||||
|  |                 builder = null; | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var equalsIndex = trimmed.IndexOf('='); | ||||||
|  |             if (equalsIndex < 0) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var key = trimmed[..equalsIndex].Trim(); | ||||||
|  |             var valuePart = trimmed[(equalsIndex + 1)..].Trim(); | ||||||
|  |             if (valuePart.Length == 0) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (valuePart[0] == '[') | ||||||
|  |             { | ||||||
|  |                 currentArrayKey = key.ToString(); | ||||||
|  |                 arrayValues.Clear(); | ||||||
|  |  | ||||||
|  |                 if (valuePart.Length > 1 && valuePart[^1] == ']') | ||||||
|  |                 { | ||||||
|  |                     var inline = valuePart[1..^1].Trim(); | ||||||
|  |                     if (inline.Length > 0) | ||||||
|  |                     { | ||||||
|  |                         foreach (var token in SplitInlineArray(inline.ToString())) | ||||||
|  |                         { | ||||||
|  |                             var parsedValue = ExtractString(token.AsSpan()); | ||||||
|  |                             if (!string.IsNullOrEmpty(parsedValue)) | ||||||
|  |                             { | ||||||
|  |                                 arrayValues.Add(parsedValue); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     builder.SetArray(currentArrayKey, arrayValues); | ||||||
|  |                     currentArrayKey = null; | ||||||
|  |                     arrayValues.Clear(); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var parsed = ExtractString(valuePart); | ||||||
|  |             if (parsed is not null) | ||||||
|  |             { | ||||||
|  |                 builder.SetField(key, parsed); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (currentArrayKey is not null && arrayValues.Count > 0) | ||||||
|  |         { | ||||||
|  |             builder?.SetArray(currentArrayKey, arrayValues); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         FlushCurrent(builder, packages); | ||||||
|  |         return packages; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static ReadOnlySpan<char> TrimComments(ReadOnlySpan<char> line) | ||||||
|  |     { | ||||||
|  |         var index = line.IndexOf('#'); | ||||||
|  |         if (index >= 0) | ||||||
|  |         { | ||||||
|  |             line = line[..index]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return line.Trim(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool IsPackageHeader(ReadOnlySpan<char> value) | ||||||
|  |         => value.SequenceEqual("[[package]]".AsSpan()); | ||||||
|  |  | ||||||
|  |     private static IEnumerable<string> SplitInlineArray(string value) | ||||||
|  |     { | ||||||
|  |         var start = 0; | ||||||
|  |         var inString = false; | ||||||
|  |  | ||||||
|  |         for (var i = 0; i < value.Length; i++) | ||||||
|  |         { | ||||||
|  |             var current = value[i]; | ||||||
|  |  | ||||||
|  |             if (current == '"') | ||||||
|  |             { | ||||||
|  |                 inString = !inString; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (current == ',' && !inString) | ||||||
|  |             { | ||||||
|  |                 var item = value.AsSpan(start, i - start).Trim(); | ||||||
|  |                 if (item.Length > 0) | ||||||
|  |                 { | ||||||
|  |                     yield return item.ToString(); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 start = i + 1; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (start < value.Length) | ||||||
|  |         { | ||||||
|  |             var item = value.AsSpan(start).Trim(); | ||||||
|  |             if (item.Length > 0) | ||||||
|  |             { | ||||||
|  |                 yield return item.ToString(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? ExtractString(ReadOnlySpan<char> value) | ||||||
|  |     { | ||||||
|  |         if (value.Length == 0) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (value[0] == '"' && value[^1] == '"') | ||||||
|  |         { | ||||||
|  |             var inner = value[1..^1]; | ||||||
|  |             return inner.ToString(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var trimmed = value.Trim(); | ||||||
|  |         return trimmed.Length == 0 ? null : trimmed.ToString(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static void FlushCurrent(RustCargoPackageBuilder? builder, List<RustCargoPackage> packages) | ||||||
|  |     { | ||||||
|  |         if (builder is null || !builder.HasData) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (builder.TryBuild(out var package)) | ||||||
|  |         { | ||||||
|  |             packages.Add(package); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class RustCargoPackageBuilder | ||||||
|  |     { | ||||||
|  |         private readonly SortedSet<string> _dependencies = new(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |         private string? _name; | ||||||
|  |         private string? _version; | ||||||
|  |         private string? _source; | ||||||
|  |         private string? _checksum; | ||||||
|  |  | ||||||
|  |         public bool HasData => !string.IsNullOrWhiteSpace(_name); | ||||||
|  |  | ||||||
|  |         public void SetField(ReadOnlySpan<char> key, string value) | ||||||
|  |         { | ||||||
|  |             if (key.SequenceEqual("name".AsSpan())) | ||||||
|  |             { | ||||||
|  |                 _name ??= value.Trim(); | ||||||
|  |             } | ||||||
|  |             else if (key.SequenceEqual("version".AsSpan())) | ||||||
|  |             { | ||||||
|  |                 _version ??= value.Trim(); | ||||||
|  |             } | ||||||
|  |             else if (key.SequenceEqual("source".AsSpan())) | ||||||
|  |             { | ||||||
|  |                 _source ??= value.Trim(); | ||||||
|  |             } | ||||||
|  |             else if (key.SequenceEqual("checksum".AsSpan())) | ||||||
|  |             { | ||||||
|  |                 _checksum ??= value.Trim(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public void SetArray(string key, IEnumerable<string> values) | ||||||
|  |         { | ||||||
|  |             if (!string.Equals(key, "dependencies", StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             foreach (var entry in values) | ||||||
|  |             { | ||||||
|  |                 if (string.IsNullOrWhiteSpace(entry)) | ||||||
|  |                 { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var normalized = entry.Trim(); | ||||||
|  |                 if (normalized.Length > 0) | ||||||
|  |                 { | ||||||
|  |                     _dependencies.Add(normalized); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public bool TryBuild(out RustCargoPackage package) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(_name)) | ||||||
|  |             { | ||||||
|  |                 package = null!; | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             package = new RustCargoPackage( | ||||||
|  |                 _name!, | ||||||
|  |                 _version ?? string.Empty, | ||||||
|  |                 _source, | ||||||
|  |                 _checksum, | ||||||
|  |                 _dependencies.ToArray()); | ||||||
|  |  | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal sealed record RustCargoPackage( | ||||||
|  |     string Name, | ||||||
|  |     string Version, | ||||||
|  |     string? Source, | ||||||
|  |     string? Checksum, | ||||||
|  |     IReadOnlyList<string> Dependencies); | ||||||
| @@ -0,0 +1,178 @@ | |||||||
|  | using System.Text.Json; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||||
|  |  | ||||||
|  | internal static class RustFingerprintScanner | ||||||
|  | { | ||||||
|  |     private static readonly EnumerationOptions Enumeration = new() | ||||||
|  |     { | ||||||
|  |         MatchCasing = MatchCasing.CaseSensitive, | ||||||
|  |         IgnoreInaccessible = true, | ||||||
|  |         RecurseSubdirectories = true, | ||||||
|  |         AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     private static readonly string FingerprintSegment = $"{Path.DirectorySeparatorChar}.fingerprint{Path.DirectorySeparatorChar}"; | ||||||
|  |  | ||||||
|  |     public static IReadOnlyList<RustFingerprintRecord> Scan(string rootPath, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(rootPath)) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentException("Root path is required", nameof(rootPath)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var results = new List<RustFingerprintRecord>(); | ||||||
|  |         foreach (var path in Directory.EnumerateFiles(rootPath, "*.json", Enumeration)) | ||||||
|  |         { | ||||||
|  |             cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |             if (!path.Contains(FingerprintSegment, StringComparison.Ordinal)) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (TryParse(path, out var record)) | ||||||
|  |             { | ||||||
|  |                 results.Add(record); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return results; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryParse(string path, out RustFingerprintRecord record) | ||||||
|  |     { | ||||||
|  |         record = default!; | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|  |             using var document = JsonDocument.Parse(stream); | ||||||
|  |             var root = document.RootElement; | ||||||
|  |  | ||||||
|  |             var pkgId = TryGetString(root, "pkgid") | ||||||
|  |                 ?? TryGetString(root, "package_id") | ||||||
|  |                 ?? TryGetString(root, "packageId"); | ||||||
|  |  | ||||||
|  |             var (name, version, source) = ParseIdentity(pkgId, path); | ||||||
|  |             if (string.IsNullOrWhiteSpace(name)) | ||||||
|  |             { | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var profile = TryGetString(root, "profile"); | ||||||
|  |             var targetKind = TryGetKind(root); | ||||||
|  |  | ||||||
|  |             record = new RustFingerprintRecord( | ||||||
|  |                 Name: name!, | ||||||
|  |                 Version: version, | ||||||
|  |                 Source: source, | ||||||
|  |                 TargetKind: targetKind, | ||||||
|  |                 Profile: profile, | ||||||
|  |                 AbsolutePath: path); | ||||||
|  |  | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         catch (JsonException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         catch (IOException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         catch (UnauthorizedAccessException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static (string? Name, string? Version, string? Source) ParseIdentity(string? pkgId, string filePath) | ||||||
|  |     { | ||||||
|  |         if (!string.IsNullOrWhiteSpace(pkgId)) | ||||||
|  |         { | ||||||
|  |             var span = pkgId.AsSpan().Trim(); | ||||||
|  |             var firstSpace = span.IndexOf(' '); | ||||||
|  |             if (firstSpace > 0 && firstSpace < span.Length - 1) | ||||||
|  |             { | ||||||
|  |                 var name = span[..firstSpace].ToString(); | ||||||
|  |                 var remaining = span[(firstSpace + 1)..].Trim(); | ||||||
|  |  | ||||||
|  |                 var secondSpace = remaining.IndexOf(' '); | ||||||
|  |                 if (secondSpace < 0) | ||||||
|  |                 { | ||||||
|  |                     return (name, remaining.ToString(), null); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var version = remaining[..secondSpace].ToString(); | ||||||
|  |                 var potentialSource = remaining[(secondSpace + 1)..].Trim(); | ||||||
|  |  | ||||||
|  |                 if (potentialSource.Length > 1 && potentialSource[0] == '(' && potentialSource[^1] == ')') | ||||||
|  |                 { | ||||||
|  |                     potentialSource = potentialSource[1..^1].Trim(); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var source = potentialSource.Length == 0 ? null : potentialSource.ToString(); | ||||||
|  |                 return (name, version, source); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var directory = Path.GetDirectoryName(filePath); | ||||||
|  |         if (string.IsNullOrEmpty(directory)) | ||||||
|  |         { | ||||||
|  |             return (null, null, null); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var crateDirectory = Path.GetFileName(directory); | ||||||
|  |         if (string.IsNullOrWhiteSpace(crateDirectory)) | ||||||
|  |         { | ||||||
|  |             return (null, null, null); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var dashIndex = crateDirectory.LastIndexOf('-'); | ||||||
|  |         if (dashIndex <= 0) | ||||||
|  |         { | ||||||
|  |             return (crateDirectory, null, null); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var maybeName = crateDirectory[..dashIndex]; | ||||||
|  |         return (maybeName, null, null); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? TryGetKind(JsonElement root) | ||||||
|  |     { | ||||||
|  |         if (root.TryGetProperty("target_kind", out var array) && array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > 0) | ||||||
|  |         { | ||||||
|  |             var first = array[0]; | ||||||
|  |             if (first.ValueKind == JsonValueKind.String) | ||||||
|  |             { | ||||||
|  |                 return first.GetString(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (root.TryGetProperty("target", out var target) && target.ValueKind == JsonValueKind.String) | ||||||
|  |         { | ||||||
|  |             return target.GetString(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? TryGetString(JsonElement element, string propertyName) | ||||||
|  |     { | ||||||
|  |         if (element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String) | ||||||
|  |         { | ||||||
|  |             return value.GetString(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | internal sealed record RustFingerprintRecord( | ||||||
|  |     string Name, | ||||||
|  |     string? Version, | ||||||
|  |     string? Source, | ||||||
|  |     string? TargetKind, | ||||||
|  |     string? Profile, | ||||||
|  |     string AbsolutePath); | ||||||
| @@ -1,6 +0,0 @@ | |||||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust; |  | ||||||
|  |  | ||||||
| internal static class Placeholder |  | ||||||
| { |  | ||||||
|     // Analyzer implementation will be added during Sprint LA5. |  | ||||||
| } |  | ||||||
| @@ -7,7 +7,7 @@ public sealed class RustAnalyzerPlugin : ILanguageAnalyzerPlugin | |||||||
| { | { | ||||||
|     public string Name => "StellaOps.Scanner.Analyzers.Lang.Rust"; |     public string Name => "StellaOps.Scanner.Analyzers.Lang.Rust"; | ||||||
|  |  | ||||||
|     public bool IsAvailable(IServiceProvider services) => false; |     public bool IsAvailable(IServiceProvider services) => services is not null; | ||||||
|  |  | ||||||
|     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) |     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
|  | using StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||||
|  |  | ||||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust; | namespace StellaOps.Scanner.Analyzers.Lang.Rust; | ||||||
|  |  | ||||||
| @@ -11,5 +12,55 @@ public sealed class RustLanguageAnalyzer : ILanguageAnalyzer | |||||||
|     public string DisplayName => "Rust Analyzer (preview)"; |     public string DisplayName => "Rust Analyzer (preview)"; | ||||||
|  |  | ||||||
|     public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) |     public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) | ||||||
|         => ValueTask.FromException(new NotImplementedException("Rust analyzer implementation pending Sprint LA5.")); |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(context); | ||||||
|  |         ArgumentNullException.ThrowIfNull(writer); | ||||||
|  |  | ||||||
|  |         cancellationToken.ThrowIfCancellationRequested(); | ||||||
|  |  | ||||||
|  |         var collection = RustAnalyzerCollector.Collect(context, cancellationToken); | ||||||
|  |  | ||||||
|  |         EmitRecords(Id, writer, collection.Crates); | ||||||
|  |         EmitRecords(Id, writer, collection.Heuristics); | ||||||
|  |         EmitRecords(Id, writer, collection.Fallbacks); | ||||||
|  |  | ||||||
|  |         return ValueTask.CompletedTask; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static void EmitRecords(string analyzerId, LanguageComponentWriter writer, IReadOnlyList<RustComponentRecord> records) | ||||||
|  |     { | ||||||
|  |         foreach (var record in records) | ||||||
|  |         { | ||||||
|  |             if (record is null) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrEmpty(record.Purl)) | ||||||
|  |             { | ||||||
|  |                 writer.AddFromPurl( | ||||||
|  |                     analyzerId: analyzerId, | ||||||
|  |                     purl: record.Purl!, | ||||||
|  |                     name: record.Name, | ||||||
|  |                     version: record.Version, | ||||||
|  |                     type: record.Type, | ||||||
|  |                     metadata: record.Metadata, | ||||||
|  |                     evidence: record.Evidence, | ||||||
|  |                     usedByEntrypoint: record.UsedByEntrypoint); | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 writer.AddFromExplicitKey( | ||||||
|  |                     analyzerId: analyzerId, | ||||||
|  |                     componentKey: record.ComponentKey, | ||||||
|  |                     purl: null, | ||||||
|  |                     name: record.Name, | ||||||
|  |                     version: record.Version, | ||||||
|  |                     type: record.Type, | ||||||
|  |                     metadata: record.Metadata, | ||||||
|  |                     evidence: record.Evidence, | ||||||
|  |                     usedByEntrypoint: record.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-306A | TODO | SCANNER-ANALYZERS-LANG-10-307 | Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence. | Fixtures confirm crate attribution ≥85 % coverage; metadata normalized; evidence includes path + hash. | | | 1 | SCANNER-ANALYZERS-LANG-10-306A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence. | Fixtures confirm crate attribution ≥85 % coverage; metadata normalized; evidence includes path + hash. | | ||||||
| | 2 | SCANNER-ANALYZERS-LANG-10-306B | TODO | SCANNER-ANALYZERS-LANG-10-306A | Implement heuristic classifier using ELF section names, symbol mangling, and `.comment` data for stripped binaries. | Heuristic output flagged as `heuristic`; regression tests ensure no false “observed” classifications. | | | 2 | SCANNER-ANALYZERS-LANG-10-306B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306A | Implement heuristic classifier using ELF section names, symbol mangling, and `.comment` data for stripped binaries. | Heuristic output flagged as `heuristic`; regression tests ensure no false “observed” classifications. | | ||||||
| | 3 | SCANNER-ANALYZERS-LANG-10-306C | TODO | SCANNER-ANALYZERS-LANG-10-306B | Integrate binary hash fallback (`bin:{sha256}`) and tie into shared quiet provenance helpers. | Fallback path deterministic; shared helpers reused; tests verify consistent hashing. | | | 3 | SCANNER-ANALYZERS-LANG-10-306C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306B | Integrate binary hash fallback (`bin:{sha256}`) and tie into shared quiet provenance helpers. | Fallback path deterministic; shared helpers reused; tests verify consistent hashing. | | ||||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307R | TODO | SCANNER-ANALYZERS-LANG-10-306C | Finalize shared helper usage (license, usage flags) and concurrency-safe caches. | Analyzer uses shared utilities; concurrency tests pass; no race conditions. | | | 4 | SCANNER-ANALYZERS-LANG-10-307R | TODO | SCANNER-ANALYZERS-LANG-10-306C | Finalize shared helper usage (license, usage flags) and concurrency-safe caches. | Analyzer uses shared utilities; concurrency tests pass; no race conditions. | | ||||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308R | TODO | SCANNER-ANALYZERS-LANG-10-307R | Determinism fixtures + performance benchmarks; compare against competitor heuristic coverage. | Fixtures `Fixtures/lang/rust/` committed; determinism guard; benchmark shows ≥15 % better coverage vs competitor. | | | 5 | SCANNER-ANALYZERS-LANG-10-308R | TODO | SCANNER-ANALYZERS-LANG-10-307R | Determinism fixtures + performance benchmarks; compare against competitor heuristic coverage. | Fixtures `Fixtures/lang/rust/` committed; determinism guard; benchmark shows ≥15 % better coverage vs competitor. | | ||||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309R | TODO | SCANNER-ANALYZERS-LANG-10-308R | Package plug-in manifest + Offline Kit documentation; ensure Worker integration. | Manifest copied; Worker loads analyzer; Offline Kit doc updated. | | | 6 | SCANNER-ANALYZERS-LANG-10-309R | TODO | SCANNER-ANALYZERS-LANG-10-308R | Package plug-in manifest + Offline Kit documentation; ensure Worker integration. | Manifest copied; Worker loads analyzer; Offline Kit doc updated. | | ||||||
|   | |||||||
| @@ -1,4 +1,7 @@ | |||||||
|  | using System; | ||||||
| using System.IO; | using System.IO; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
| using StellaOps.Scanner.Analyzers.Lang.DotNet; | using StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||||
| 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; | ||||||
| @@ -25,4 +28,79 @@ public sealed class DotNetLanguageAnalyzerTests | |||||||
|             analyzers, |             analyzers, | ||||||
|             cancellationToken); |             cancellationToken); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task SignedFixtureCapturesAssemblyMetadataAsync() | ||||||
|  |     { | ||||||
|  |         var cancellationToken = TestContext.Current.CancellationToken; | ||||||
|  |         var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "signed"); | ||||||
|  |         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||||
|  |  | ||||||
|  |         var analyzers = new ILanguageAnalyzer[] | ||||||
|  |         { | ||||||
|  |             new DotNetLanguageAnalyzer() | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         var inspector = new StubAuthenticodeInspector(); | ||||||
|  |         var services = new SingleServiceProvider(inspector); | ||||||
|  |  | ||||||
|  |         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||||
|  |             fixturePath, | ||||||
|  |             goldenPath, | ||||||
|  |             analyzers, | ||||||
|  |             cancellationToken, | ||||||
|  |             usageHints: null, | ||||||
|  |             services: services); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task SelfContainedFixtureHandlesNativeAssetsAndUsageAsync() | ||||||
|  |     { | ||||||
|  |         var cancellationToken = TestContext.Current.CancellationToken; | ||||||
|  |         var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "selfcontained"); | ||||||
|  |         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||||
|  |  | ||||||
|  |         var usageHints = new LanguageUsageHints(new[] | ||||||
|  |         { | ||||||
|  |             Path.Combine(fixturePath, "lib", "net10.0", "StellaOps.Toolkit.dll"), | ||||||
|  |             Path.Combine(fixturePath, "runtimes", "linux-x64", "native", "libstellaopsnative.so") | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         var analyzers = new ILanguageAnalyzer[] | ||||||
|  |         { | ||||||
|  |             new DotNetLanguageAnalyzer() | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||||
|  |             fixturePath, | ||||||
|  |             goldenPath, | ||||||
|  |             analyzers, | ||||||
|  |             cancellationToken, | ||||||
|  |             usageHints); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class StubAuthenticodeInspector : IDotNetAuthenticodeInspector | ||||||
|  |     { | ||||||
|  |         public DotNetAuthenticodeMetadata? TryInspect(string assemblyPath, CancellationToken cancellationToken) | ||||||
|  |             => new DotNetAuthenticodeMetadata( | ||||||
|  |                 Subject: "CN=StellaOps Test Signing", | ||||||
|  |                 Issuer: "CN=StellaOps Root", | ||||||
|  |                 NotBefore: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), | ||||||
|  |                 NotAfter: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), | ||||||
|  |                 Thumbprint: "AA11BB22CC33DD44EE55FF66GG77HH88II99JJ00", | ||||||
|  |                 SerialNumber: "0123456789ABCDEF"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class SingleServiceProvider : IServiceProvider | ||||||
|  |     { | ||||||
|  |         private readonly object _service; | ||||||
|  |  | ||||||
|  |         public SingleServiceProvider(object service) | ||||||
|  |         { | ||||||
|  |             _service = service; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public object? GetService(Type serviceType) | ||||||
|  |             => serviceType == typeof(IDotNetAuthenticodeInspector) ? _service : null; | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,85 @@ | |||||||
|  | { | ||||||
|  |   "runtimeTarget": { | ||||||
|  |     "name": ".NETCoreApp,Version=v10.0", | ||||||
|  |     "signature": null | ||||||
|  |   }, | ||||||
|  |   "targets": { | ||||||
|  |     ".NETCoreApp,Version=v10.0/linux-x64": { | ||||||
|  |       "MyApp/1.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "StellaOps.Toolkit": "1.2.3", | ||||||
|  |           "StellaOps.Runtime.SelfContained": "2.1.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "MyApp.dll": {} | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "StellaOps.Toolkit/1.2.3": { | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net10.0/StellaOps.Toolkit.dll": { | ||||||
|  |             "assemblyVersion": "1.2.3.0", | ||||||
|  |             "fileVersion": "1.2.3.0" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "StellaOps.Runtime.SelfContained/2.1.0": { | ||||||
|  |         "runtimeTargets": { | ||||||
|  |           "runtimes/linux-x64/native/libstellaopsnative.so": { | ||||||
|  |             "rid": "linux-x64", | ||||||
|  |             "assetType": "native" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     ".NETCoreApp,Version=v10.0/win-x64": { | ||||||
|  |       "MyApp/1.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "StellaOps.Toolkit": "1.2.3", | ||||||
|  |           "StellaOps.Runtime.SelfContained": "2.1.0" | ||||||
|  |         }, | ||||||
|  |         "runtime": { | ||||||
|  |           "MyApp.dll": {} | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "StellaOps.Toolkit/1.2.3": { | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net10.0/StellaOps.Toolkit.dll": { | ||||||
|  |             "assemblyVersion": "1.2.3.0", | ||||||
|  |             "fileVersion": "1.2.3.0" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "StellaOps.Runtime.SelfContained/2.1.0": { | ||||||
|  |         "runtimeTargets": { | ||||||
|  |           "runtimes/win-x64/native/stellaopsnative.dll": { | ||||||
|  |             "rid": "win-x64", | ||||||
|  |             "assetType": "native" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "libraries": { | ||||||
|  |     "MyApp/1.0.0": { | ||||||
|  |       "type": "project", | ||||||
|  |       "serviceable": false, | ||||||
|  |       "sha512": "", | ||||||
|  |       "path": null, | ||||||
|  |       "hashPath": null | ||||||
|  |     }, | ||||||
|  |     "StellaOps.Toolkit/1.2.3": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-FAKE_TOOLKIT_SHA==", | ||||||
|  |       "path": "stellaops.toolkit/1.2.3", | ||||||
|  |       "hashPath": "stellaops.toolkit.1.2.3.nupkg.sha512" | ||||||
|  |     }, | ||||||
|  |     "StellaOps.Runtime.SelfContained/2.1.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-FAKE_RUNTIME_SHA==", | ||||||
|  |       "path": "stellaops.runtime.selfcontained/2.1.0", | ||||||
|  |       "hashPath": "stellaops.runtime.selfcontained.2.1.0.nupkg.sha512" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,15 @@ | |||||||
|  | { | ||||||
|  |   "runtimeOptions": { | ||||||
|  |     "tfm": "net10.0", | ||||||
|  |     "framework": { | ||||||
|  |       "name": "Microsoft.NETCore.App", | ||||||
|  |       "version": "10.0.0" | ||||||
|  |     }, | ||||||
|  |     "includedFrameworks": [ | ||||||
|  |       { | ||||||
|  |         "name": "Microsoft.NETCore.DotNetAppHost", | ||||||
|  |         "version": "10.0.0" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,101 @@ | |||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "analyzerId": "dotnet", | ||||||
|  |     "componentKey": "purl::pkg:nuget/stellaops.runtime.selfcontained@2.1.0", | ||||||
|  |     "purl": "pkg:nuget/stellaops.runtime.selfcontained@2.1.0", | ||||||
|  |     "name": "StellaOps.Runtime.SelfContained", | ||||||
|  |     "version": "2.1.0", | ||||||
|  |     "type": "nuget", | ||||||
|  |     "usedByEntrypoint": true, | ||||||
|  |     "metadata": { | ||||||
|  |       "deps.path[0]": "MyApp.deps.json", | ||||||
|  |       "deps.rid[0]": "linux-x64", | ||||||
|  |       "deps.rid[1]": "win-x64", | ||||||
|  |       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "native[0].assetPath": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||||
|  |       "native[0].path": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||||
|  |       "native[0].rid[0]": "linux-x64", | ||||||
|  |       "native[0].sha256": "c22d4a6584a3bb8fad4d255d1ab9e5a80d553eec35ea8dfcc2dd750e8581d3cb", | ||||||
|  |       "native[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "native[1].assetPath": "runtimes/win-x64/native/stellaopsnative.dll", | ||||||
|  |       "native[1].path": "runtimes/win-x64/native/stellaopsnative.dll", | ||||||
|  |       "native[1].rid[0]": "win-x64", | ||||||
|  |       "native[1].sha256": "29cddd69702aedc715050304bec85aad2ae017ee1f9390df5e68ebe79a8d4745", | ||||||
|  |       "native[1].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "package.hashPath[0]": "stellaops.runtime.selfcontained.2.1.0.nupkg.sha512", | ||||||
|  |       "package.id": "StellaOps.Runtime.SelfContained", | ||||||
|  |       "package.id.normalized": "stellaops.runtime.selfcontained", | ||||||
|  |       "package.path[0]": "stellaops.runtime.selfcontained/2.1.0", | ||||||
|  |       "package.serviceable": "true", | ||||||
|  |       "package.sha512[0]": "sha512-FAKE_RUNTIME_SHA==", | ||||||
|  |       "package.version": "2.1.0" | ||||||
|  |     }, | ||||||
|  |     "evidence": [ | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "deps.json", | ||||||
|  |         "locator": "MyApp.deps.json", | ||||||
|  |         "value": "StellaOps.Runtime.SelfContained/2.1.0" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "native", | ||||||
|  |         "locator": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||||
|  |         "value": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||||
|  |         "sha256": "c22d4a6584a3bb8fad4d255d1ab9e5a80d553eec35ea8dfcc2dd750e8581d3cb" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "native", | ||||||
|  |         "locator": "runtimes/win-x64/native/stellaopsnative.dll", | ||||||
|  |         "value": "runtimes/win-x64/native/stellaopsnative.dll", | ||||||
|  |         "sha256": "29cddd69702aedc715050304bec85aad2ae017ee1f9390df5e68ebe79a8d4745" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     "analyzerId": "dotnet", | ||||||
|  |     "componentKey": "purl::pkg:nuget/stellaops.toolkit@1.2.3", | ||||||
|  |     "purl": "pkg:nuget/stellaops.toolkit@1.2.3", | ||||||
|  |     "name": "StellaOps.Toolkit", | ||||||
|  |     "version": "1.2.3", | ||||||
|  |     "type": "nuget", | ||||||
|  |     "usedByEntrypoint": true, | ||||||
|  |     "metadata": { | ||||||
|  |       "assembly[0].assetPath": "lib/net10.0/StellaOps.Toolkit.dll", | ||||||
|  |       "assembly[0].fileVersion": "1.2.3.0", | ||||||
|  |       "assembly[0].path": "lib/net10.0/StellaOps.Toolkit.dll", | ||||||
|  |       "assembly[0].rid[0]": "linux-x64", | ||||||
|  |       "assembly[0].rid[1]": "win-x64", | ||||||
|  |       "assembly[0].sha256": "5b82fd11cf6c2ba6b351592587c4203f6af48b89427b954903534eac0e9f17f7", | ||||||
|  |       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "assembly[0].version": "1.2.3.0", | ||||||
|  |       "deps.path[0]": "MyApp.deps.json", | ||||||
|  |       "deps.rid[0]": "linux-x64", | ||||||
|  |       "deps.rid[1]": "win-x64", | ||||||
|  |       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "package.hashPath[0]": "stellaops.toolkit.1.2.3.nupkg.sha512", | ||||||
|  |       "package.id": "StellaOps.Toolkit", | ||||||
|  |       "package.id.normalized": "stellaops.toolkit", | ||||||
|  |       "package.path[0]": "stellaops.toolkit/1.2.3", | ||||||
|  |       "package.serviceable": "true", | ||||||
|  |       "package.sha512[0]": "sha512-FAKE_TOOLKIT_SHA==", | ||||||
|  |       "package.version": "1.2.3" | ||||||
|  |     }, | ||||||
|  |     "evidence": [ | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "assembly", | ||||||
|  |         "locator": "lib/net10.0/StellaOps.Toolkit.dll", | ||||||
|  |         "value": "lib/net10.0/StellaOps.Toolkit.dll", | ||||||
|  |         "sha256": "5b82fd11cf6c2ba6b351592587c4203f6af48b89427b954903534eac0e9f17f7" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "deps.json", | ||||||
|  |         "locator": "MyApp.deps.json", | ||||||
|  |         "value": "StellaOps.Toolkit/1.2.3" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   } | ||||||
|  | ] | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | ELF FAKE STELLAOPS NATIVE LIB | ||||||
| @@ -0,0 +1,42 @@ | |||||||
|  | { | ||||||
|  |   "runtimeTarget": { | ||||||
|  |     "name": ".NETCoreApp,Version=v10.0/linux-x64" | ||||||
|  |   }, | ||||||
|  |   "targets": { | ||||||
|  |     ".NETCoreApp,Version=v10.0": { | ||||||
|  |       "Signed.App/1.0.0": { | ||||||
|  |         "dependencies": { | ||||||
|  |           "Microsoft.Extensions.Logging": "9.0.0" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "Microsoft.Extensions.Logging/9.0.0": { | ||||||
|  |         "runtime": { | ||||||
|  |           "lib/net9.0/Microsoft.Extensions.Logging.dll": { | ||||||
|  |             "assemblyVersion": "9.0.0.0", | ||||||
|  |             "fileVersion": "9.0.24.52809" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     ".NETCoreApp,Version=v10.0/linux-x64": { | ||||||
|  |       "Microsoft.Extensions.Logging/9.0.0": { | ||||||
|  |         "runtime": { | ||||||
|  |           "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll": {} | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "libraries": { | ||||||
|  |     "Signed.App/1.0.0": { | ||||||
|  |       "type": "project", | ||||||
|  |       "serviceable": false | ||||||
|  |     }, | ||||||
|  |     "Microsoft.Extensions.Logging/9.0.0": { | ||||||
|  |       "type": "package", | ||||||
|  |       "serviceable": true, | ||||||
|  |       "sha512": "sha512-FAKE_LOGGING_SHA==", | ||||||
|  |       "path": "microsoft.extensions.logging/9.0.0", | ||||||
|  |       "hashPath": "microsoft.extensions.logging.9.0.0.nupkg.sha512" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | { | ||||||
|  |   "runtimeOptions": { | ||||||
|  |     "tfm": "net10.0", | ||||||
|  |     "framework": { | ||||||
|  |       "name": "Microsoft.NETCore.App", | ||||||
|  |       "version": "10.0.0" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,59 @@ | |||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "analyzerId": "dotnet", | ||||||
|  |     "componentKey": "purl::pkg:nuget/microsoft.extensions.logging@9.0.0", | ||||||
|  |     "purl": "pkg:nuget/microsoft.extensions.logging@9.0.0", | ||||||
|  |     "name": "Microsoft.Extensions.Logging", | ||||||
|  |     "version": "9.0.0", | ||||||
|  |     "type": "nuget", | ||||||
|  |     "usedByEntrypoint": false, | ||||||
|  |     "metadata": { | ||||||
|  |       "assembly[0].assetPath": "lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |       "assembly[0].authenticode.issuer": "CN=StellaOps Root", | ||||||
|  |       "assembly[0].authenticode.notAfter": "2026-01-01T00:00:00.000Z", | ||||||
|  |       "assembly[0].authenticode.notBefore": "2025-01-01T00:00:00.000Z", | ||||||
|  |       "assembly[0].authenticode.serialNumber": "0123456789ABCDEF", | ||||||
|  |       "assembly[0].authenticode.subject": "CN=StellaOps Test Signing", | ||||||
|  |       "assembly[0].authenticode.thumbprint": "AA11BB22CC33DD44EE55FF66GG77HH88II99JJ00", | ||||||
|  |       "assembly[0].company": "Microsoft Corporation", | ||||||
|  |       "assembly[0].fileDescription": "Microsoft.Extensions.Logging", | ||||||
|  |       "assembly[0].fileVersion": "9.0.24.52809", | ||||||
|  |       "assembly[0].path": "packages/microsoft.extensions.logging/9.0.0/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |       "assembly[0].product": "Microsoft\u00ae .NET", | ||||||
|  |       "assembly[0].productVersion": "9.0.0+9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3", | ||||||
|  |       "assembly[0].publicKeyToken": "adb9793829ddae60", | ||||||
|  |       "assembly[0].sha256": "faed6cb5c9ca0d6077feaeb2df251251adccf0241f7a80b91c58e014cd5ad48f", | ||||||
|  |       "assembly[0].strongName": "Microsoft.Extensions.Logging, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", | ||||||
|  |       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "assembly[0].version": "9.0.0.0", | ||||||
|  |       "assembly[1].assetPath": "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |       "assembly[1].rid[0]": "linux-x64", | ||||||
|  |       "assembly[1].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "deps.path[0]": "Signed.App.deps.json", | ||||||
|  |       "deps.rid[0]": "linux-x64", | ||||||
|  |       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "package.hashPath[0]": "microsoft.extensions.logging.9.0.0.nupkg.sha512", | ||||||
|  |       "package.id": "Microsoft.Extensions.Logging", | ||||||
|  |       "package.id.normalized": "microsoft.extensions.logging", | ||||||
|  |       "package.path[0]": "microsoft.extensions.logging/9.0.0", | ||||||
|  |       "package.serviceable": "true", | ||||||
|  |       "package.sha512[0]": "sha512-FAKE_LOGGING_SHA==", | ||||||
|  |       "package.version": "9.0.0" | ||||||
|  |     }, | ||||||
|  |     "evidence": [ | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "assembly", | ||||||
|  |         "locator": "packages/microsoft.extensions.logging/9.0.0/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |         "value": "lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |         "sha256": "faed6cb5c9ca0d6077feaeb2df251251adccf0241f7a80b91c58e014cd5ad48f" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "deps.json", | ||||||
|  |         "locator": "Signed.App.deps.json", | ||||||
|  |         "value": "Microsoft.Extensions.Logging/9.0.0" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   } | ||||||
|  | ] | ||||||
| @@ -8,6 +8,16 @@ | |||||||
|     "type": "nuget", |     "type": "nuget", | ||||||
|     "usedByEntrypoint": false, |     "usedByEntrypoint": false, | ||||||
|     "metadata": { |     "metadata": { | ||||||
|  |       "assembly[0].assetPath": "lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |       "assembly[0].fileVersion": "9.0.24.52809", | ||||||
|  |       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "assembly[0].version": "9.0.0.0", | ||||||
|  |       "assembly[1].assetPath": "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |       "assembly[1].rid[0]": "linux-x64", | ||||||
|  |       "assembly[1].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "assembly[2].assetPath": "runtimes/win-x86/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||||
|  |       "assembly[2].rid[0]": "win-x86", | ||||||
|  |       "assembly[2].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|       "deps.path[0]": "Sample.App.deps.json", |       "deps.path[0]": "Sample.App.deps.json", | ||||||
|       "deps.rid[0]": "linux-x64", |       "deps.rid[0]": "linux-x64", | ||||||
|       "deps.rid[1]": "win-x86", |       "deps.rid[1]": "win-x86", | ||||||
| @@ -38,6 +48,10 @@ | |||||||
|     "type": "nuget", |     "type": "nuget", | ||||||
|     "usedByEntrypoint": false, |     "usedByEntrypoint": false, | ||||||
|     "metadata": { |     "metadata": { | ||||||
|  |       "assembly[0].assetPath": "lib/net10.0/StellaOps.Toolkit.dll", | ||||||
|  |       "assembly[0].fileVersion": "1.2.3.0", | ||||||
|  |       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||||
|  |       "assembly[0].version": "1.2.3.0", | ||||||
|       "deps.dependency[0]": "microsoft.extensions.logging", |       "deps.dependency[0]": "microsoft.extensions.logging", | ||||||
|       "deps.path[0]": "Sample.App.deps.json", |       "deps.path[0]": "Sample.App.deps.json", | ||||||
|       "deps.rid[0]": "linux-x64", |       "deps.rid[0]": "linux-x64", | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/rust/simple/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/rust/simple/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | [[package]] | ||||||
|  | name = "my_app" | ||||||
|  | version = "0.1.0" | ||||||
|  | dependencies = [ | ||||||
|  |  "serde", | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "serde" | ||||||
|  | version = "1.0.188" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "abc123" | ||||||
| @@ -0,0 +1,90 @@ | |||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "analyzerId": "rust", | ||||||
|  |     "componentKey": "bin::sha256:22caa7413d89026b52db64c8abc254bf9e7647ab9216e79c6972a39451f8c41e", | ||||||
|  |     "name": "unknown_tool", | ||||||
|  |     "type": "bin", | ||||||
|  |     "usedByEntrypoint": false, | ||||||
|  |     "metadata": { | ||||||
|  |       "binary.path": "usr/local/bin/unknown_tool", | ||||||
|  |       "binary.sha256": "22caa7413d89026b52db64c8abc254bf9e7647ab9216e79c6972a39451f8c41e", | ||||||
|  |       "provenance": "binary" | ||||||
|  |     }, | ||||||
|  |     "evidence": [ | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "binary", | ||||||
|  |         "locator": "usr/local/bin/unknown_tool", | ||||||
|  |         "sha256": "22caa7413d89026b52db64c8abc254bf9e7647ab9216e79c6972a39451f8c41e" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     "analyzerId": "rust", | ||||||
|  |     "componentKey": "purl::pkg:cargo/my_app@0.1.0", | ||||||
|  |     "purl": "pkg:cargo/my_app@0.1.0", | ||||||
|  |     "name": "my_app", | ||||||
|  |     "version": "0.1.0", | ||||||
|  |     "type": "cargo", | ||||||
|  |     "usedByEntrypoint": true, | ||||||
|  |     "metadata": { | ||||||
|  |       "binary.paths": "usr/local/bin/my_app", | ||||||
|  |       "binary.sha256": "a95a4f4854bf973deacbd937bd1189fc3d0eef7a4fd4f7960f37cf66162c82fd", | ||||||
|  |       "cargo.lock.path": "Cargo.lock", | ||||||
|  |       "fingerprint.profile": "debug", | ||||||
|  |       "fingerprint.targetKind": "bin", | ||||||
|  |       "source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index" | ||||||
|  |     }, | ||||||
|  |     "evidence": [ | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "binary", | ||||||
|  |         "locator": "usr/local/bin/my_app", | ||||||
|  |         "sha256": "a95a4f4854bf973deacbd937bd1189fc3d0eef7a4fd4f7960f37cf66162c82fd" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "cargo.fingerprint", | ||||||
|  |         "locator": "target/debug/.fingerprint/my_app-1234567890abcdef/bin-my_app-1234567890abcdef.json", | ||||||
|  |         "value": "bin" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "cargo.lock", | ||||||
|  |         "locator": "Cargo.lock", | ||||||
|  |         "value": "my_app 0.1.0" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     "analyzerId": "rust", | ||||||
|  |     "componentKey": "purl::pkg:cargo/serde@1.0.188", | ||||||
|  |     "purl": "pkg:cargo/serde@1.0.188", | ||||||
|  |     "name": "serde", | ||||||
|  |     "version": "1.0.188", | ||||||
|  |     "type": "cargo", | ||||||
|  |     "usedByEntrypoint": false, | ||||||
|  |     "metadata": { | ||||||
|  |       "cargo.lock.path": "Cargo.lock", | ||||||
|  |       "checksum": "abc123", | ||||||
|  |       "fingerprint.profile": "release", | ||||||
|  |       "fingerprint.targetKind": "lib", | ||||||
|  |       "source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index" | ||||||
|  |     }, | ||||||
|  |     "evidence": [ | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "cargo.fingerprint", | ||||||
|  |         "locator": "target/debug/.fingerprint/serde-abcdef1234567890/libserde-abcdef1234567890.json", | ||||||
|  |         "value": "lib" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "file", | ||||||
|  |         "source": "cargo.lock", | ||||||
|  |         "locator": "Cargo.lock", | ||||||
|  |         "value": "serde 1.0.188", | ||||||
|  |         "sha256": "abc123" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   } | ||||||
|  | ] | ||||||
| @@ -0,0 +1,5 @@ | |||||||
|  | { | ||||||
|  |   "pkgid": "my_app 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", | ||||||
|  |   "profile": "debug", | ||||||
|  |   "target_kind": ["bin"] | ||||||
|  | } | ||||||
| @@ -0,0 +1,5 @@ | |||||||
|  | { | ||||||
|  |   "pkgid": "serde 1.0.188 (registry+https://github.com/rust-lang/crates.io-index)", | ||||||
|  |   "profile": "release", | ||||||
|  |   "target_kind": ["lib"] | ||||||
|  | } | ||||||
| @@ -4,7 +4,7 @@ namespace StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | |||||||
|  |  | ||||||
| public static class LanguageAnalyzerTestHarness | public static class LanguageAnalyzerTestHarness | ||||||
| { | { | ||||||
|     public static async Task<string> RunToJsonAsync(string fixturePath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null) |     public static async Task<string> RunToJsonAsync(string fixturePath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null, IServiceProvider? services = null) | ||||||
|     { |     { | ||||||
|         if (string.IsNullOrWhiteSpace(fixturePath)) |         if (string.IsNullOrWhiteSpace(fixturePath)) | ||||||
|         { |         { | ||||||
| @@ -12,14 +12,14 @@ public static class LanguageAnalyzerTestHarness | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var engine = new LanguageAnalyzerEngine(analyzers ?? Array.Empty<ILanguageAnalyzer>()); |         var engine = new LanguageAnalyzerEngine(analyzers ?? Array.Empty<ILanguageAnalyzer>()); | ||||||
|         var context = new LanguageAnalyzerContext(fixturePath, TimeProvider.System, usageHints); |         var context = new LanguageAnalyzerContext(fixturePath, TimeProvider.System, usageHints, services); | ||||||
|         var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); |         var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); | ||||||
|         return result.ToJson(indent: true); |         return result.ToJson(indent: true); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public static async Task AssertDeterministicAsync(string fixturePath, string goldenPath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null) |     public static async Task AssertDeterministicAsync(string fixturePath, string goldenPath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null, IServiceProvider? services = null) | ||||||
|     { |     { | ||||||
|         var actual = await RunToJsonAsync(fixturePath, analyzers, cancellationToken, usageHints).ConfigureAwait(false); |         var actual = await RunToJsonAsync(fixturePath, analyzers, cancellationToken, usageHints, services).ConfigureAwait(false); | ||||||
|         var expected = await File.ReadAllTextAsync(goldenPath, cancellationToken).ConfigureAwait(false); |         var expected = await File.ReadAllTextAsync(goldenPath, cancellationToken).ConfigureAwait(false); | ||||||
|  |  | ||||||
|         // Normalize newlines for portability. |         // Normalize newlines for portability. | ||||||
|   | |||||||
| @@ -0,0 +1,34 @@ | |||||||
|  | using System.IO; | ||||||
|  | using StellaOps.Scanner.Analyzers.Lang.Rust; | ||||||
|  | using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||||
|  | using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Scanner.Analyzers.Lang.Tests.Rust; | ||||||
|  |  | ||||||
|  | public sealed class RustLanguageAnalyzerTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public async Task SimpleFixtureProducesDeterministicOutputAsync() | ||||||
|  |     { | ||||||
|  |         var cancellationToken = TestContext.Current.CancellationToken; | ||||||
|  |         var fixturePath = TestPaths.ResolveFixture("lang", "rust", "simple"); | ||||||
|  |         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||||
|  |  | ||||||
|  |         var usageHints = new LanguageUsageHints(new[] | ||||||
|  |         { | ||||||
|  |             Path.Combine(fixturePath, "usr/local/bin/my_app") | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         var analyzers = new ILanguageAnalyzer[] | ||||||
|  |         { | ||||||
|  |             new RustLanguageAnalyzer() | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||||
|  |             fixturePath, | ||||||
|  |             goldenPath, | ||||||
|  |             analyzers, | ||||||
|  |             cancellationToken, | ||||||
|  |             usageHints); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -32,6 +32,7 @@ | |||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> |     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.DotNet\StellaOps.Scanner.Analyzers.Lang.DotNet.csproj" /> |     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.DotNet\StellaOps.Scanner.Analyzers.Lang.DotNet.csproj" /> | ||||||
|  |     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Rust\StellaOps.Scanner.Analyzers.Lang.Rust.csproj" /> | ||||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> |     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -51,7 +51,7 @@ All sprints below assume prerequisites from SP10-G2 (core scaffolding + Java ana | |||||||
| - **Gate Artifacts:** | - **Gate Artifacts:** | ||||||
|   - Benchmarks vs competitor open-source tool (Trivy or Syft) demonstrating faster metadata extraction. |   - Benchmarks vs competitor open-source tool (Trivy or Syft) demonstrating faster metadata extraction. | ||||||
|   - Documentation snippet explaining VCS metadata fields for Policy team. |   - Documentation snippet explaining VCS metadata fields for Policy team. | ||||||
| - **Progress (2025-10-22):** Build-info decoder shipped with DWARF-string fallback for `vcs.*` markers, plus cached metadata keyed by binary length/timestamp. Added Go test fixtures covering build-info and DWARF-only binaries with deterministic goldens; analyzer now emits `go.dwarf` evidence alongside `go.buildinfo` metadata to feed downstream provenance rules. | - **Progress (2025-10-22):** Build-info decoder shipped with DWARF-string fallback for `vcs.*` markers, plus cached metadata keyed by binary length/timestamp. Added Go test fixtures covering build-info and DWARF-only binaries with deterministic goldens; analyzer now emits `go.dwarf` evidence alongside `go.buildinfo` metadata to feed downstream provenance rules. Completed stripped-binary heuristics with deterministic `golang::bin::sha256` components and a new `stripped` fixture to guard quiet-provenance behaviour. | ||||||
|  |  | ||||||
| ## Sprint LA4 — .NET Analyzer & RID Variants (Tasks 10-305, 10-307, 10-308, 10-309 subset) | ## Sprint LA4 — .NET Analyzer & RID Variants (Tasks 10-305, 10-307, 10-308, 10-309 subset) | ||||||
| - **Scope:** Parse `*.deps.json`, `runtimeconfig.json`, assembly metadata, and RID-specific assets; correlate with native dependencies. | - **Scope:** Parse `*.deps.json`, `runtimeconfig.json`, assembly metadata, and RID-specific assets; correlate with native dependencies. | ||||||
|   | |||||||
| @@ -6,8 +6,8 @@ | |||||||
| | SCANNER-ANALYZERS-LANG-10-302 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Node analyzer resolving workspaces/symlinks into `pkg:npm` identities. | Node analyzer handles symlinks/workspaces; outputs sorted components; determinism harness covers hoisted deps. | | | SCANNER-ANALYZERS-LANG-10-302 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Node analyzer resolving workspaces/symlinks into `pkg:npm` identities. | Node analyzer handles symlinks/workspaces; outputs sorted components; determinism harness covers hoisted deps. | | ||||||
| | SCANNER-ANALYZERS-LANG-10-303 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Python analyzer consuming `*.dist-info` metadata and RECORD hashes. | Analyzer binds METADATA + RECORD evidence, includes entry points, determinism fixtures stable. | | | SCANNER-ANALYZERS-LANG-10-303 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Python analyzer consuming `*.dist-info` metadata and RECORD hashes. | Analyzer binds METADATA + RECORD evidence, includes entry points, determinism fixtures stable. | | ||||||
| | SCANNER-ANALYZERS-LANG-10-304 | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Go analyzer leveraging buildinfo for `pkg:golang` components. | Buildinfo parser emits module path/version + vcs metadata; binaries without buildinfo downgraded gracefully. | | | SCANNER-ANALYZERS-LANG-10-304 | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Go analyzer leveraging buildinfo for `pkg:golang` components. | Buildinfo parser emits module path/version + vcs metadata; binaries without buildinfo downgraded gracefully. | | ||||||
| | SCANNER-ANALYZERS-LANG-10-305 | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants. | Analyzer merges deps.json + assembly info; dedupes per RID; determinism verified. | | | SCANNER-ANALYZERS-LANG-10-305 | DONE (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants. | Analyzer merges deps.json + assembly info; dedupes per RID; determinism verified. | | ||||||
| | SCANNER-ANALYZERS-LANG-10-306 | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Rust analyzer detecting crate provenance or falling back to `bin:{sha256}`. | Analyzer emits `pkg:cargo` when metadata present; falls back to binary hash; fixtures cover both paths. | | | SCANNER-ANALYZERS-LANG-10-306 | DONE (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Rust analyzer detecting crate provenance or falling back to `bin:{sha256}`. | Analyzer emits `pkg:cargo` when metadata present; falls back to binary hash; fixtures cover both paths. | | ||||||
| | SCANNER-ANALYZERS-LANG-10-307 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-CORE-09-501 | Shared language evidence helpers + usage flag propagation. | Shared abstractions implemented; analyzers reuse helpers; evidence includes usage hints; unit tests cover canonical ordering. | | | SCANNER-ANALYZERS-LANG-10-307 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-CORE-09-501 | Shared language evidence helpers + usage flag propagation. | Shared abstractions implemented; analyzers reuse helpers; evidence includes usage hints; unit tests cover canonical ordering. | | ||||||
| | SCANNER-ANALYZERS-LANG-10-308 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Determinism + fixture harness for language analyzers. | Harness executes analyzers against fixtures; golden JSON stored; CI helper ensures stable hashes. | | | SCANNER-ANALYZERS-LANG-10-308 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Determinism + fixture harness for language analyzers. | Harness executes analyzers against fixtures; golden JSON stored; CI helper ensures stable hashes. | | ||||||
| | SCANNER-ANALYZERS-LANG-10-309 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-301..308 | Package language analyzers as restart-time plug-ins (manifest + host registration). | Plugin manifests authored under `plugins/scanner/analyzers/lang`; Worker loads via DI; restart required flag enforced; tests confirm manifest integrity. | | | SCANNER-ANALYZERS-LANG-10-309 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-301..308 | Package language analyzers as restart-time plug-ins (manifest + host registration). | Plugin manifests authored under `plugins/scanner/analyzers/lang`; Worker loads via DI; restart required flag enforced; tests confirm manifest integrity. | | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user