Add comprehensive tests for Go and Python version conflict detection and licensing normalization
- Implemented GoVersionConflictDetectorTests to validate pseudo-version detection, conflict analysis, and conflict retrieval for Go modules. - Created VersionConflictDetectorTests for Python to assess conflict detection across various version scenarios, including major, minor, and patch differences. - Added SpdxLicenseNormalizerTests to ensure accurate normalization of SPDX license strings and classifiers. - Developed VendoredPackageDetectorTests to identify vendored packages and extract embedded packages from Python packages, including handling of vendor directories and known vendored packages.
This commit is contained in:
@@ -20,7 +20,10 @@
|
|||||||
"WebFetch(domain:docs.gradle.org)",
|
"WebFetch(domain:docs.gradle.org)",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"Bash(dotnet msbuild:*)",
|
"Bash(dotnet msbuild:*)",
|
||||||
"Bash(test:*)"
|
"Bash(test:*)",
|
||||||
|
"Bash(taskkill:*)",
|
||||||
|
"Bash(timeout /t)",
|
||||||
|
"Bash(dotnet clean:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -16,10 +16,17 @@
|
|||||||
<StellaOpsEnableCryptoPro Condition="'$(StellaOpsEnableCryptoPro)' == ''">false</StellaOpsEnableCryptoPro>
|
<StellaOpsEnableCryptoPro Condition="'$(StellaOpsEnableCryptoPro)' == ''">false</StellaOpsEnableCryptoPro>
|
||||||
<NoWarn>$(NoWarn);NU1608</NoWarn>
|
<NoWarn>$(NoWarn);NU1608</NoWarn>
|
||||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1608</WarningsNotAsErrors>
|
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1608</WarningsNotAsErrors>
|
||||||
|
<RestoreNoWarn>$(RestoreNoWarn);NU1608</RestoreNoWarn>
|
||||||
|
<RestoreWarningsAsErrors></RestoreWarningsAsErrors>
|
||||||
|
<RestoreTreatWarningsAsErrors>false</RestoreTreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
<PropertyGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
||||||
<DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_PRO</DefineConstants>
|
<DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_PRO</DefineConstants>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Update="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
# BLOCKED Tasks Dependency Tree
|
# BLOCKED Tasks Dependency Tree
|
||||||
> **Last Updated:** 2025-12-06 (Wave 8: 56 specs created)
|
> **Last Updated:** 2025-12-06 (Wave 8+: 56 specs + 12 sprint updates)
|
||||||
> **Current Status:** 400 BLOCKED | 316 TODO | 1631 DONE
|
> **Current Status:** 148 BLOCKED | 338 TODO | 572+ DONE
|
||||||
> **Purpose:** This document maps all BLOCKED tasks and their root causes to help teams prioritize unblocking work.
|
> **Purpose:** This document maps all BLOCKED tasks and their root causes to help teams prioritize unblocking work.
|
||||||
> **Note:** Specifications created in Waves 1-8 provide contracts to unblock tasks; sprint files need `BLOCKED → TODO` updates.
|
|
||||||
> **Visual DAG:** See [DEPENDENCY_DAG.md](./DEPENDENCY_DAG.md) for Mermaid graphs, cascade analysis, and guild blocking matrix.
|
> **Visual DAG:** See [DEPENDENCY_DAG.md](./DEPENDENCY_DAG.md) for Mermaid graphs, cascade analysis, and guild blocking matrix.
|
||||||
>
|
>
|
||||||
|
> **Sprint File Updates (2025-12-06 — Post-Wave 8):**
|
||||||
|
> - ✅ SPRINT_0150 (Scheduling & Automation): AirGap staleness (0120.A 56-002/57/58) → DONE; 150.A only blocked on Scanner Java chain
|
||||||
|
> - ✅ SPRINT_0161 (EvidenceLocker): Schema blockers RESOLVED; EVID-OBS-54-002 → TODO
|
||||||
|
> - ✅ SPRINT_0140 (Runtime & Signals): 140.C Signals wave → TODO (CAS APPROVED + Provenance appendix published)
|
||||||
|
> - ✅ SPRINT_0143 (Signals): SIGNALS-24-002/003 → TODO (CAS Infrastructure APPROVED)
|
||||||
|
> - ✅ SPRINT_0160 (Export Evidence): 160.A/B snapshots → TODO (orchestrator/advisory schemas available)
|
||||||
|
> - ✅ SPRINT_0121 (Policy Reasoning): LEDGER-OAS-61-001-DEV, LEDGER-PACKS-42-001-DEV → TODO
|
||||||
|
> - ✅ SPRINT_0120 (Policy Reasoning): LEDGER-AIRGAP-56-002/57/58 → DONE; LEDGER-ATTEST-73-001 → TODO
|
||||||
|
> - ✅ SPRINT_0136 (Scanner Surface): SCANNER-EVENTS-16-301 → TODO
|
||||||
|
>
|
||||||
> **Recent Unblocks (2025-12-06 Wave 8):**
|
> **Recent Unblocks (2025-12-06 Wave 8):**
|
||||||
> - ✅ Ledger Time-Travel API (`docs/schemas/ledger-time-travel-api.openapi.yaml`) — 73+ tasks (Export Center chains SPRINT_0160-0164)
|
> - ✅ Ledger Time-Travel API (`docs/schemas/ledger-time-travel-api.openapi.yaml`) — 73+ tasks (Export Center chains SPRINT_0160-0164)
|
||||||
> - ✅ Graph Platform API (`docs/schemas/graph-platform-api.openapi.yaml`) — 11+ tasks (SPRINT_0209_ui_i, GRAPH-28-007 through 28-010)
|
> - ✅ Graph Platform API (`docs/schemas/graph-platform-api.openapi.yaml`) — 11+ tasks (SPRINT_0209_ui_i, GRAPH-28-007 through 28-010)
|
||||||
|
|||||||
92
docs/implplan/SPRINT_0134_0001_0001_native_analyzer_fixes.md
Normal file
92
docs/implplan/SPRINT_0134_0001_0001_native_analyzer_fixes.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Sprint 0134 · Native Analyzer Bug Fixes
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Bug fixes and feature completion for native binary analyzers (ELF, PE, Mach-O)
|
||||||
|
- Address critical bugs discovered in code review: PE 64-bit parsing, PE resource extraction, ELF version needs
|
||||||
|
- Bring ELF/PE analyzers to feature parity with Mach-O analyzer
|
||||||
|
- **Working directory:** `src/Scanner/StellaOps.Scanner.Analyzers.Native` (and this tracking file under `docs/implplan`)
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Upstream: Sprint 0132 · Scanner & Surface (native analyzers implemented but with bugs)
|
||||||
|
- Tasks 1-3 are independent bug fixes and can proceed in parallel
|
||||||
|
- Tasks 4-6 are tests that depend on their respective bug fixes
|
||||||
|
- Task 7 is a feature addition that can proceed independently
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- docs/README.md
|
||||||
|
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||||
|
- docs/modules/scanner/architecture.md
|
||||||
|
- src/Scanner/AGENTS.md
|
||||||
|
|
||||||
|
## Problem Summary
|
||||||
|
|
||||||
|
### PE Analyzer Bugs (PeImportParser.cs)
|
||||||
|
1. **Line 234**: `is64Bit: false` hardcoded in `ParseImportDirectory` - breaks 64-bit PE import parsing
|
||||||
|
2. **Lines 462-473**: `ParseSectionHeaders(span, 0, 0)` returns empty list - resource manifest always falls back to text search
|
||||||
|
|
||||||
|
### ELF Analyzer Gap (ElfDynamicSectionParser.cs)
|
||||||
|
1. **Lines 374-395**: `ParseVersionNeeds` returns empty dictionary - GLIBC version requirements never extracted
|
||||||
|
|
||||||
|
### Reference Implementation
|
||||||
|
- Mach-O analyzer is feature-complete with weak/reexport/lazy classification, version parsing, and comprehensive tests
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
| # | Task ID | Status | Key dependency / next step | Task Definition |
|
||||||
|
|---|---------|--------|----------------------------|-----------------|
|
||||||
|
| 1 | NATIVE-FIX-PE-64BIT | TODO | None | Fix PE import parser 64-bit thunk parsing. Thread `is64Bit` through `ParseImportDirectory` method signature or refactor to capture in parser state. Location: `PeImportParser.cs:234` |
|
||||||
|
| 2 | NATIVE-FIX-PE-RESOURCE | TODO | None | Fix PE resource manifest extraction. Pass `List<SectionInfo> sections` to `FindFirstResourceData`, use proper RVA-to-file-offset conversion instead of text search fallback. Location: `PeImportParser.cs:462-473` |
|
||||||
|
| 3 | NATIVE-FIX-ELF-VERNEED | TODO | None | Implement ELF version needs parsing. Parse section headers to find `.gnu.version_r` section, parse `Elf64_Verneed` (16 bytes) and `Elf64_Vernaux` (16 bytes) structures, map version requirements to parent library. Location: `ElfDynamicSectionParser.cs:374-395` |
|
||||||
|
| 4 | NATIVE-TEST-PE-64BIT | TODO | NATIVE-FIX-PE-64BIT | Add PE 64-bit import parsing test to `PeImportParserTests.cs`. Create synthetic PE32+ binary with import table, verify correct thunk parsing (8-byte entries). |
|
||||||
|
| 5 | NATIVE-TEST-PE-MANIFEST | TODO | NATIVE-FIX-PE-RESOURCE | Add PE proper resource manifest test to `PeImportParserTests.cs`. Create synthetic PE with embedded RT_MANIFEST resource, verify extraction via resource directory (not text search). |
|
||||||
|
| 6 | NATIVE-TEST-ELF-VERNEED | TODO | NATIVE-FIX-ELF-VERNEED | Add ELF version needs parsing test to `ElfDynamicSectionParserTests.cs`. Create synthetic ELF with `.gnu.version_r` section containing GLIBC_2.17 requirement, verify extraction. |
|
||||||
|
| 7 | NATIVE-FEATURE-ELF-WEAK | TODO | None | Add ELF weak symbol detection for parity with Mach-O. Parse symbol table for STB_WEAK binding, emit separate reason code for weak dependencies. |
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### ELF Verneed Structure (Elf64_Verneed - 16 bytes)
|
||||||
|
```
|
||||||
|
vn_version (2 bytes) - version of structure (should be 1)
|
||||||
|
vn_cnt (2 bytes) - number of Vernaux entries
|
||||||
|
vn_file (4 bytes) - offset to filename in strtab
|
||||||
|
vn_aux (4 bytes) - offset to first Vernaux entry
|
||||||
|
vn_next (4 bytes) - offset to next Verneed entry (0 if last)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ELF Vernaux Structure (Elf64_Vernaux - 16 bytes)
|
||||||
|
```
|
||||||
|
vna_hash (4 bytes) - hash of version name
|
||||||
|
vna_flags (2 bytes) - flags (VER_FLG_WEAK = 0x2)
|
||||||
|
vna_other (2 bytes) - version index
|
||||||
|
vna_name (4 bytes) - offset to version string in strtab
|
||||||
|
vna_next (4 bytes) - offset to next Vernaux entry (0 if last)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PE Import Thunk Size
|
||||||
|
- PE32 (32-bit): 4 bytes per thunk entry
|
||||||
|
- PE32+ (64-bit): 8 bytes per thunk entry
|
||||||
|
- Current code hardcodes 4 bytes, breaking 64-bit PE parsing
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
|------------|--------|-------|
|
||||||
|
| 2025-12-07 | Sprint created based on code review of native analyzers; identified 2 PE bugs and 1 ELF placeholder | Implementer |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- PE resource parsing fix may require broader refactoring if section headers aren't available in the right scope
|
||||||
|
- ELF version needs parsing adds complexity; consider performance impact on large binaries
|
||||||
|
- Mach-O analyzer is the reference implementation for feature parity goals
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Task | Effort | Risk |
|
||||||
|
|------|--------|------|
|
||||||
|
| PE bitness fix | 30 min | Low |
|
||||||
|
| PE resource parsing fix | 2-4 hours | Medium |
|
||||||
|
| PE tests | 1 hour | Low |
|
||||||
|
| ELF version needs impl | 4-8 hours | Medium |
|
||||||
|
| ELF weak symbol detection | 2-4 hours | Low |
|
||||||
|
| ELF tests | 2 hours | Low |
|
||||||
|
|
||||||
|
**Total: 1-2 days for critical fixes, 3-4 days for complete feature parity with Mach-O**
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
| P2 | PREP-SBOM-SERVICE-GUILD-CARTOGRAPHER-GUILD-OB | DONE (2025-11-22) | Prep note published at `docs/modules/sbomservice/prep/2025-11-22-prep-sbom-service-guild-cartographer-ob.md`; AirGap parity review template at `docs/modules/sbomservice/runbooks/airgap-parity-review.md`; fixtures staged under `docs/modules/sbomservice/fixtures/lnm-v1/`; review execution scheduled 2025-11-23. | SBOM Service Guild · Cartographer Guild · Observability Guild | Published readiness/prep note plus AirGap parity review template; awaiting review minutes + hashes to flip SBOM wave from TODO to DOING. |
|
| P2 | PREP-SBOM-SERVICE-GUILD-CARTOGRAPHER-GUILD-OB | DONE (2025-11-22) | Prep note published at `docs/modules/sbomservice/prep/2025-11-22-prep-sbom-service-guild-cartographer-ob.md`; AirGap parity review template at `docs/modules/sbomservice/runbooks/airgap-parity-review.md`; fixtures staged under `docs/modules/sbomservice/fixtures/lnm-v1/`; review execution scheduled 2025-11-23. | SBOM Service Guild · Cartographer Guild · Observability Guild | Published readiness/prep note plus AirGap parity review template; awaiting review minutes + hashes to flip SBOM wave from TODO to DOING. |
|
||||||
| 1 | 140.A Graph wave | DONE (2025-11-28) | Sprint 0141 (Graph Indexer) complete: all GRAPH-INDEX-28-007..010 tasks DONE. | Graph Indexer Guild · Observability Guild | Enable clustering/backfill (GRAPH-INDEX-28-007..010) against mock bundle; revalidate once real cache lands. |
|
| 1 | 140.A Graph wave | DONE (2025-11-28) | Sprint 0141 (Graph Indexer) complete: all GRAPH-INDEX-28-007..010 tasks DONE. | Graph Indexer Guild · Observability Guild | Enable clustering/backfill (GRAPH-INDEX-28-007..010) against mock bundle; revalidate once real cache lands. |
|
||||||
| 2 | 140.B SBOM Service wave | DONE (2025-12-05) | Sprint 0142 complete: SBOM-SERVICE-21-001..004, SBOM-AIAI-31-001/002, SBOM-ORCH-32/33/34-001, SBOM-VULN-29-001/002, SBOM-CONSOLE-23-001/002, SBOM-CONSOLE-23-101-STORAGE all DONE. | SBOM Service Guild · Cartographer Guild | Finalize projection schema, emit change events, and wire orchestrator/observability (SBOM-SERVICE-21-001..004, SBOM-AIAI-31-001/002). |
|
| 2 | 140.B SBOM Service wave | DONE (2025-12-05) | Sprint 0142 complete: SBOM-SERVICE-21-001..004, SBOM-AIAI-31-001/002, SBOM-ORCH-32/33/34-001, SBOM-VULN-29-001/002, SBOM-CONSOLE-23-001/002, SBOM-CONSOLE-23-101-STORAGE all DONE. | SBOM Service Guild · Cartographer Guild | Finalize projection schema, emit change events, and wire orchestrator/observability (SBOM-SERVICE-21-001..004, SBOM-AIAI-31-001/002). |
|
||||||
| 3 | 140.C Signals wave | BLOCKED (2025-12-05) | CAS promotion + provenance appendix overdue; SIGNALS-24-002/003 cannot proceed until Storage approval + provenance freeze. | Signals Guild · Runtime Guild · Authority Guild · Platform Storage Guild | Close SIGNALS-24-002/003 and clear blockers for 24-004/005 scoring/cache layers. |
|
| 3 | 140.C Signals wave | TODO | ✅ CAS APPROVED (2025-12-06): Contract at `docs/contracts/cas-infrastructure.md`. ✅ Provenance appendix published at `docs/signals/provenance-24-003.md` + schema at `docs/schemas/provenance-feed.schema.json`. SIGNALS-24-002/003 now unblocked; ready for implementation. | Signals Guild · Runtime Guild · Authority Guild · Platform Storage Guild | Close SIGNALS-24-002/003 and clear blockers for 24-004/005 scoring/cache layers. |
|
||||||
| 4 | 140.D Zastava wave | DONE (2025-11-28) | Sprint 0144 (Zastava Runtime Signals) complete: all ZASTAVA-ENV/SECRETS/SURFACE tasks DONE. | Zastava Observer/Webhook Guilds · Surface Guild | Prepare env/secret helpers and admission hooks; start once cache endpoints and helpers are published. |
|
| 4 | 140.D Zastava wave | DONE (2025-11-28) | Sprint 0144 (Zastava Runtime Signals) complete: all ZASTAVA-ENV/SECRETS/SURFACE tasks DONE. | Zastava Observer/Webhook Guilds · Surface Guild | Prepare env/secret helpers and admission hooks; start once cache endpoints and helpers are published. |
|
||||||
| 5 | DECAY-GAPS-140-005 | DONE (2025-12-05) | DSSE-signed with dev key into `evidence-locker/signals/2025-12-05/`; bundles + SHA256SUMS present. | Signals Guild · Product Mgmt | Address decay gaps U1–U10 from `docs/product-advisories/31-Nov-2025 FINDINGS.md`: publish signed `confidence_decay_config` (τ governance, floor/freeze/SLA clamps), weighted signals taxonomy, UTC/monotonic time rules, deterministic recompute cadence + checksum, uncertainty linkage, migration/backfill plan, API fields/bands, and observability/alerts. |
|
| 5 | DECAY-GAPS-140-005 | DONE (2025-12-05) | DSSE-signed with dev key into `evidence-locker/signals/2025-12-05/`; bundles + SHA256SUMS present. | Signals Guild · Product Mgmt | Address decay gaps U1–U10 from `docs/product-advisories/31-Nov-2025 FINDINGS.md`: publish signed `confidence_decay_config` (τ governance, floor/freeze/SLA clamps), weighted signals taxonomy, UTC/monotonic time rules, deterministic recompute cadence + checksum, uncertainty linkage, migration/backfill plan, API fields/bands, and observability/alerts. |
|
||||||
| 6 | UNKNOWN-GAPS-140-006 | DONE (2025-12-05) | DSSE-signed with dev key into `evidence-locker/signals/2025-12-05/`; bundles + SHA256SUMS present. | Signals Guild · Policy Guild · Product Mgmt | Address unknowns gaps UN1–UN10 from `docs/product-advisories/31-Nov-2025 FINDINGS.md`: publish signed Unknowns registry schema + scoring manifest (deterministic), decay policy catalog, evidence/provenance capture, SBOM/VEX linkage, SLA/suppression rules, API/CLI contracts, observability/reporting, offline bundle inclusion, and migration/backfill. |
|
| 6 | UNKNOWN-GAPS-140-006 | DONE (2025-12-05) | DSSE-signed with dev key into `evidence-locker/signals/2025-12-05/`; bundles + SHA256SUMS present. | Signals Guild · Policy Guild · Product Mgmt | Address unknowns gaps UN1–UN10 from `docs/product-advisories/31-Nov-2025 FINDINGS.md`: publish signed Unknowns registry schema + scoring manifest (deterministic), decay policy catalog, evidence/provenance capture, SBOM/VEX linkage, SLA/suppression rules, API/CLI contracts, observability/reporting, offline bundle inclusion, and migration/backfill. |
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | **140.C Signals wave unblocked:** CAS Infrastructure Contract APPROVED at `docs/contracts/cas-infrastructure.md`; Provenance appendix published at `docs/signals/provenance-24-003.md` + schema at `docs/schemas/provenance-feed.schema.json`. SIGNALS-24-002/003 moved from BLOCKED to TODO. | Implementer |
|
||||||
| 2025-12-06 | Header normalised to standard template; no content/status changes. | Project Mgmt |
|
| 2025-12-06 | Header normalised to standard template; no content/status changes. | Project Mgmt |
|
||||||
| 2025-12-05 | SBOM wave 140.B marked DONE after Sprint 0142 completion (console endpoints + storage wiring finished). | Implementer |
|
| 2025-12-05 | SBOM wave 140.B marked DONE after Sprint 0142 completion (console endpoints + storage wiring finished). | Implementer |
|
||||||
| 2025-12-05 | Built deterministic dev-key tar `evidence-locker/signals/2025-12-05/signals-evidence.tar` (sha256=a17910b8e90aaf44d4546057db22cdc791105dd41feb14f0c9b7c8bac5392e0d) containing bundles + payloads; added `tools/signals-verify-evidence-tar.sh` (hash + inner SHA check). Production re-sign still pending Alice Carter key/CI secret. | Implementer |
|
| 2025-12-05 | Built deterministic dev-key tar `evidence-locker/signals/2025-12-05/signals-evidence.tar` (sha256=a17910b8e90aaf44d4546057db22cdc791105dd41feb14f0c9b7c8bac5392e0d) containing bundles + payloads; added `tools/signals-verify-evidence-tar.sh` (hash + inner SHA check). Production re-sign still pending Alice Carter key/CI secret. | Implementer |
|
||||||
@@ -202,8 +203,8 @@ This file now only tracks the runtime & signals status snapshot. Active backlog
|
|||||||
| Task ID | State | Notes |
|
| Task ID | State | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| SIGNALS-24-001 | DONE (2025-11-09) | Host skeleton, RBAC, sealed-mode readiness, `/signals/facts/{subject}` retrieval, and readiness probes merged; serves as base for downstream ingestion. |
|
| SIGNALS-24-001 | DONE (2025-11-09) | Host skeleton, RBAC, sealed-mode readiness, `/signals/facts/{subject}` retrieval, and readiness probes merged; serves as base for downstream ingestion. |
|
||||||
| SIGNALS-24-002 | BLOCKED (2025-11-19) | Callgraph ingestion + retrieval APIs are live, but CAS promotion and signed manifest publication remain; cannot close until reachability jobs can trust stored graphs. |
|
| SIGNALS-24-002 | TODO (2025-12-06) | ✅ CAS APPROVED at `docs/contracts/cas-infrastructure.md`. Callgraph ingestion + retrieval APIs are live; CAS promotion approved; ready for signed manifest publication and reachability job trust configuration. |
|
||||||
| SIGNALS-24-003 | BLOCKED (2025-11-19) | Runtime facts ingestion accepts JSON/NDJSON and gzip streams; provenance/context enrichment and NDJSON-to-AOC wiring still outstanding. |
|
| SIGNALS-24-003 | TODO (2025-12-06) | ✅ Provenance appendix at `docs/signals/provenance-24-003.md` + schema at `docs/schemas/provenance-feed.schema.json`. Runtime facts ingestion ready for provenance/context enrichment and NDJSON-to-AOC wiring. |
|
||||||
| SIGNALS-24-004 | BLOCKED (2025-10-27) | Reachability scoring waits on complete ingestion feeds (24-002/003) plus Authority scope validation. |
|
| SIGNALS-24-004 | BLOCKED (2025-10-27) | Reachability scoring waits on complete ingestion feeds (24-002/003) plus Authority scope validation. |
|
||||||
| SIGNALS-24-005 | BLOCKED (2025-10-27) | Cache + `signals.fact.updated` events depend on scoring outputs; remains idle until 24-004 unblocks. |
|
| SIGNALS-24-005 | BLOCKED (2025-10-27) | Cache + `signals.fact.updated` events depend on scoring outputs; remains idle until 24-004 unblocks. |
|
||||||
|
|
||||||
@@ -323,8 +324,8 @@ This file now only tracks the runtime & signals status snapshot. Active backlog
|
|||||||
| Item | Status | Next step | Owner(s) | Due |
|
| Item | Status | Next step | Owner(s) | Due |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Prod DSSE re-sign (Signals gaps) | TODO | Provide Alice Carter production key via `COSIGN_PRIVATE_KEY_B64` or `tools/cosign/cosign.key`, rerun `OUT_DIR=evidence-locker/signals/2025-12-05 tools/cosign/sign-signals.sh` to replace dev bundles; upload refreshed SHA256SUMS. | Signals Guild · Platform / Build Guild | 2025-12-06 |
|
| Prod DSSE re-sign (Signals gaps) | TODO | Provide Alice Carter production key via `COSIGN_PRIVATE_KEY_B64` or `tools/cosign/cosign.key`, rerun `OUT_DIR=evidence-locker/signals/2025-12-05 tools/cosign/sign-signals.sh` to replace dev bundles; upload refreshed SHA256SUMS. | Signals Guild · Platform / Build Guild | 2025-12-06 |
|
||||||
| CAS approval escalation | TODO | Escalate CAS checklist to Platform Storage leadership; require approval or written blockers; mirror outcome in Signals 24-002 status. | Signals Guild · Platform Storage Guild | 2025-12-06 |
|
| CAS approval escalation | ✅ DONE | CAS Infrastructure Contract APPROVED at `docs/contracts/cas-infrastructure.md` (2025-12-06); SIGNALS-24-002 unblocked. | Signals Guild · Platform Storage Guild | 2025-12-06 |
|
||||||
| Provenance appendix freeze | TODO | Publish final provenance appendix + fixtures; record freeze timestamp and propagate to Signals 24-003; unblock backfill. | Runtime Guild · Authority Guild | 2025-12-07 |
|
| Provenance appendix freeze | ✅ DONE | Provenance appendix published at `docs/signals/provenance-24-003.md`; schema at `docs/schemas/provenance-feed.schema.json`. SIGNALS-24-003 unblocked. | Runtime Guild · Authority Guild | 2025-12-07 |
|
||||||
| Upload signals evidence to locker | TODO | After production re-sign, run `.gitea/workflows/signals-evidence-locker.yml` or `tools/signals-verify-evidence-tar.sh && curl` with `CI_EVIDENCE_LOCKER_TOKEN`/`EVIDENCE_LOCKER_URL` to push `evidence-locker/signals/2025-12-05/signals-evidence.tar`. | Signals Guild · Platform / Build Guild | 2025-12-07 |
|
| Upload signals evidence to locker | TODO | After production re-sign, run `.gitea/workflows/signals-evidence-locker.yml` or `tools/signals-verify-evidence-tar.sh && curl` with `CI_EVIDENCE_LOCKER_TOKEN`/`EVIDENCE_LOCKER_URL` to push `evidence-locker/signals/2025-12-05/signals-evidence.tar`. | Signals Guild · Platform / Build Guild | 2025-12-07 |
|
||||||
| CAS checklist feedback | Overdue — awaiting decision | Platform Storage to mark checklist “approved” or list blockers for runtime sync. | Platform Storage Guild | 2025-11-13 |
|
| CAS checklist feedback | Overdue — awaiting decision | Platform Storage to mark checklist “approved” or list blockers for runtime sync. | Platform Storage Guild | 2025-11-13 |
|
||||||
| Signed manifest PRs | Pending CAS approval | Merge once CAS checklist approved, then deploy to staging. | Signals Guild | 2025-11-14 |
|
| Signed manifest PRs | Pending CAS approval | Merge once CAS checklist approved, then deploy to staging. | Signals Guild | 2025-11-14 |
|
||||||
|
|||||||
@@ -25,15 +25,15 @@
|
|||||||
| P2 | PREP-SIGNALS-24-002-CAS-PROMO | DONE (2025-11-19) | Due 2025-11-22 · Accountable: Signals Guild · Platform Storage Guild | Signals Guild · Platform Storage Guild | CAS promotion checklist and manifest schema published at `docs/signals/cas-promotion-24-002.md`; awaiting storage approval to execute. |
|
| P2 | PREP-SIGNALS-24-002-CAS-PROMO | DONE (2025-11-19) | Due 2025-11-22 · Accountable: Signals Guild · Platform Storage Guild | Signals Guild · Platform Storage Guild | CAS promotion checklist and manifest schema published at `docs/signals/cas-promotion-24-002.md`; awaiting storage approval to execute. |
|
||||||
| P3 | PREP-SIGNALS-24-003-PROVENANCE | DONE (2025-11-19) | Due 2025-11-22 · Accountable: Signals Guild · Runtime Guild · Authority Guild | Signals Guild · Runtime Guild · Authority Guild | Provenance appendix fields and checklist published at `docs/signals/provenance-24-003.md`; awaiting schema/signing approval to execute. |
|
| P3 | PREP-SIGNALS-24-003-PROVENANCE | DONE (2025-11-19) | Due 2025-11-22 · Accountable: Signals Guild · Runtime Guild · Authority Guild | Signals Guild · Runtime Guild · Authority Guild | Provenance appendix fields and checklist published at `docs/signals/provenance-24-003.md`; awaiting schema/signing approval to execute. |
|
||||||
| 1 | SIGNALS-24-001 | DONE (2025-11-09) | Dependency AUTH-SIG-26-001; merged host skeleton with scope policies and evidence validation. | Signals Guild, Authority Guild | Stand up Signals API skeleton with RBAC, sealed-mode config, DPoP/mTLS enforcement, and `/facts` scaffolding so downstream ingestion can begin. |
|
| 1 | SIGNALS-24-001 | DONE (2025-11-09) | Dependency AUTH-SIG-26-001; merged host skeleton with scope policies and evidence validation. | Signals Guild, Authority Guild | Stand up Signals API skeleton with RBAC, sealed-mode config, DPoP/mTLS enforcement, and `/facts` scaffolding so downstream ingestion can begin. |
|
||||||
| 2 | SIGNALS-24-002 | BLOCKED (2025-11-19) | Await Platform Storage approval; CAS promotion checklist ready (see PREP-SIGNALS-24-002-CAS-PROMO). | Signals Guild | Implement callgraph ingestion/normalization (Java/Node/Python/Go) with CAS persistence and retrieval APIs to feed reachability scoring. |
|
| 2 | SIGNALS-24-002 | TODO | ✅ CAS APPROVED (2025-12-06): Contract at `docs/contracts/cas-infrastructure.md`; provenance schema at `docs/schemas/provenance-feed.schema.json`. Ready for implementation. | Signals Guild | Implement callgraph ingestion/normalization (Java/Node/Python/Go) with CAS persistence and retrieval APIs to feed reachability scoring. |
|
||||||
| 3 | SIGNALS-24-003 | BLOCKED (2025-11-19) | Blocked on SIGNALS-24-002 approval and provenance schema sign-off; checklist ready (PREP-SIGNALS-24-003-PROVENANCE). | Signals Guild, Runtime Guild | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance. |
|
| 3 | SIGNALS-24-003 | TODO | ✅ CAS approved + provenance schema available at `docs/schemas/provenance-feed.schema.json`. Ready for implementation. | Signals Guild, Runtime Guild | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance. |
|
||||||
| 4 | SIGNALS-24-004 | DONE (2025-11-17) | Scoring weights now configurable; runtime ingestion auto-triggers recompute into `reachability_facts`. | Signals Guild, Data Science | Deliver reachability scoring engine producing states/scores and writing to `reachability_facts`; expose configuration for weights. |
|
| 4 | SIGNALS-24-004 | DONE (2025-11-17) | Scoring weights now configurable; runtime ingestion auto-triggers recompute into `reachability_facts`. | Signals Guild, Data Science | Deliver reachability scoring engine producing states/scores and writing to `reachability_facts`; expose configuration for weights. |
|
||||||
| 5 | SIGNALS-24-005 | DONE (2025-11-26) | PREP-SIGNALS-24-005-REDIS-CACHE-IMPLEMENTED-A | Signals Guild, Platform Events Guild | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. |
|
| 5 | SIGNALS-24-005 | DONE (2025-11-26) | PREP-SIGNALS-24-005-REDIS-CACHE-IMPLEMENTED-A | Signals Guild, Platform Events Guild | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. |
|
||||||
|
|
||||||
## Action Tracker
|
## Action Tracker
|
||||||
| Action | Owner(s) | Due | Status | Next step |
|
| Action | Owner(s) | Due | Status | Next step |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| CAS approval decision (SIGNALS-24-002) | Signals Guild · Platform Storage Guild | 2025-12-06 | PENDING | Await leadership response; flip to DOING and merge manifests if approved, else capture blockers in Decisions & Risks. |
|
| CAS approval decision (SIGNALS-24-002) | Signals Guild · Platform Storage Guild | 2025-12-06 | ✅ DONE | CAS Infrastructure Contract APPROVED at `docs/contracts/cas-infrastructure.md`. SIGNALS-24-002/003 unblocked. |
|
||||||
| Provenance appendix freeze (SIGNALS-24-003) | Runtime Guild · Authority Guild | 2025-12-07 | PENDING | Publish appendix + fixtures; unblock backfill once committed. |
|
| Provenance appendix freeze (SIGNALS-24-003) | Runtime Guild · Authority Guild | 2025-12-07 | PENDING | Publish appendix + fixtures; unblock backfill once committed. |
|
||||||
| Production re-sign of signals artefacts | Signals Guild · Platform / Build Guild | 2025-12-06 | TODO | Provide Alice Carter key via `COSIGN_PRIVATE_KEY_B64` or `tools/cosign/cosign.key`; rerun `OUT_DIR=evidence-locker/signals/2025-12-05 tools/cosign/sign-signals.sh`; refresh SHA256SUMS. |
|
| Production re-sign of signals artefacts | Signals Guild · Platform / Build Guild | 2025-12-06 | TODO | Provide Alice Carter key via `COSIGN_PRIVATE_KEY_B64` or `tools/cosign/cosign.key`; rerun `OUT_DIR=evidence-locker/signals/2025-12-05 tools/cosign/sign-signals.sh`; refresh SHA256SUMS. |
|
||||||
| Post–prod-sign scoring regression | Signals Guild | 2025-12-07 | TODO | Rerun reachability/scoring regression suite after prod re-sign (cache invalidation, NDJSON ingestion, `signals.fact.updated` payloads). |
|
| Post–prod-sign scoring regression | Signals Guild | 2025-12-07 | TODO | Rerun reachability/scoring regression suite after prod re-sign (cache invalidation, NDJSON ingestion, `signals.fact.updated` payloads). |
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | **CAS Blocker Resolved:** SIGNALS-24-002 and SIGNALS-24-003 changed from BLOCKED to TODO. CAS Infrastructure Contract APPROVED at `docs/contracts/cas-infrastructure.md`; provenance schema at `docs/schemas/provenance-feed.schema.json`. Ready for implementation. | Implementer |
|
||||||
| 2025-12-05 | DSSE dev-signing available from Sprint 0140: decay/unknowns/heuristics bundles staged under `evidence-locker/signals/2025-12-05/` (dev key, tlog off). Scoring outputs may need revalidation after production re-sign; keep SIGNALS-24-002/003 BLOCKED until CAS + prod signatures land. | Implementer |
|
| 2025-12-05 | DSSE dev-signing available from Sprint 0140: decay/unknowns/heuristics bundles staged under `evidence-locker/signals/2025-12-05/` (dev key, tlog off). Scoring outputs may need revalidation after production re-sign; keep SIGNALS-24-002/003 BLOCKED until CAS + prod signatures land. | Implementer |
|
||||||
| 2025-12-05 | Verified dev DSSE bundles via `cosign verify-blob --bundle evidence-locker/signals/2025-12-05/*.sigstore.json --key tools/cosign/cosign.dev.pub` (all OK). Pending production re-sign once Alice Carter key available. | Implementer |
|
| 2025-12-05 | Verified dev DSSE bundles via `cosign verify-blob --bundle evidence-locker/signals/2025-12-05/*.sigstore.json --key tools/cosign/cosign.dev.pub` (all OK). Pending production re-sign once Alice Carter key available. | Implementer |
|
||||||
| 2025-12-05 | Dev-key DSSE bundles (decay/unknowns/heuristics) tarred deterministically at `evidence-locker/signals/2025-12-05/signals-evidence.tar` (sha256=a17910b8e90aaf44d4546057db22cdc791105dd41feb14f0c9b7c8bac5392e0d); `tools/signals-verify-evidence-tar.sh` added. Production re-sign still pending Alice Carter key/CI secret. | Project Mgmt |
|
| 2025-12-05 | Dev-key DSSE bundles (decay/unknowns/heuristics) tarred deterministically at `evidence-locker/signals/2025-12-05/signals-evidence.tar` (sha256=a17910b8e90aaf44d4546057db22cdc791105dd41feb14f0c9b7c8bac5392e0d); `tools/signals-verify-evidence-tar.sh` added. Production re-sign still pending Alice Carter key/CI secret. | Project Mgmt |
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
## Delivery Tracker
|
## Delivery Tracker
|
||||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 1 | 150.A-Orchestrator | BLOCKED | Graph (0140.A) ✅ DONE; Zastava (0140.D) ✅ DONE. Blocked on 0120.A AirGap staleness (56-002/57/58) and Scanner surface Java/Lang chain (0131). | Orchestrator Service Guild · AirGap Policy/Controller Guilds · Observability Guild | Kick off orchestration scheduling/telemetry baseline for automation epic. |
|
| 1 | 150.A-Orchestrator | BLOCKED | Graph (0140.A) ✅ DONE; Zastava (0140.D) ✅ DONE; AirGap (0120.A) ✅ DONE (2025-12-06). Blocked on Scanner surface Java/Lang chain (0131 21-005..011). | Orchestrator Service Guild · AirGap Policy/Controller Guilds · Observability Guild | Kick off orchestration scheduling/telemetry baseline for automation epic. |
|
||||||
| 2 | 150.B-PacksRegistry | BLOCKED | 150.A must reach DOING; confirm tenancy scaffolding from Orchestrator | Packs Registry Guild · Exporter Guild · Security Guild | Packs registry automation stream staged; start after Orchestrator scaffolding. |
|
| 2 | 150.B-PacksRegistry | BLOCKED | 150.A must reach DOING; confirm tenancy scaffolding from Orchestrator | Packs Registry Guild · Exporter Guild · Security Guild | Packs registry automation stream staged; start after Orchestrator scaffolding. |
|
||||||
| 3 | 150.C-Scheduler | BLOCKED | Graph ✅ DONE; still waiting on Scanner surface Java/Lang chain (0131 21-005..011) | Scheduler WebService/Worker Guilds · Findings Ledger Guild · Observability Guild | Scheduler impact index improvements gated on Graph overlays. |
|
| 3 | 150.C-Scheduler | BLOCKED | Graph ✅ DONE; still waiting on Scanner surface Java/Lang chain (0131 21-005..011) | Scheduler WebService/Worker Guilds · Findings Ledger Guild · Observability Guild | Scheduler impact index improvements gated on Graph overlays. |
|
||||||
| 4 | 150.D-TaskRunner | BLOCKED | Requires Orchestrator/Scheduler telemetry baselines (150.A/150.C) | Task Runner Guild · AirGap Guilds · Evidence Locker Guild | Execution engine upgrades and evidence integration to start post-baselines. |
|
| 4 | 150.D-TaskRunner | BLOCKED | Requires Orchestrator/Scheduler telemetry baselines (150.A/150.C) | Task Runner Guild · AirGap Guilds · Evidence Locker Guild | Execution engine upgrades and evidence integration to start post-baselines. |
|
||||||
@@ -31,14 +31,16 @@
|
|||||||
## Wave Coordination Snapshot
|
## Wave Coordination Snapshot
|
||||||
| Wave | Guild owners | Shared prerequisites | Status | Notes |
|
| Wave | Guild owners | Shared prerequisites | Status | Notes |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 150.A Orchestrator | Orchestrator Service Guild · AirGap Policy/Controller Guilds · Observability Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Graph (0140.A) and Zastava (0140.D) DONE. AirGap staleness (0120.A 56-002/57/58) and Scanner surface Java/Lang chain (0131 21-005..011) still blocking kickoff. |
|
| 150.A Orchestrator | Orchestrator Service Guild · AirGap Policy/Controller Guilds · Observability Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Graph (0140.A) ✅ DONE; Zastava (0140.D) ✅ DONE; AirGap staleness (0120.A 56-002/57/58) ✅ DONE (2025-12-06). Only Scanner surface Java/Lang chain (0131 21-005..011) remains blocking. |
|
||||||
| 150.B PacksRegistry | Packs Registry Guild · Exporter Guild · Security Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Blocked on Orchestrator tenancy scaffolding; specs ready once 150.A enters DOING. |
|
| 150.B PacksRegistry | Packs Registry Guild · Exporter Guild · Security Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Blocked on Orchestrator tenancy scaffolding; specs ready once 150.A enters DOING. |
|
||||||
| 150.C Scheduler | Scheduler WebService/Worker Guilds · Findings Ledger Guild · Observability Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Graph overlays (0140.A) DONE; Scanner surface Java/Lang chain still blocked; Signals CAS/DSSE signing (0140.C) pending for telemetry parity. |
|
| 150.C Scheduler | Scheduler WebService/Worker Guilds · Findings Ledger Guild · Observability Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Graph overlays (0140.A) DONE; Scanner surface Java/Lang chain still blocked; ✅ Signals 140.C unblocked (2025-12-06): CAS APPROVED + Provenance appendix published. |
|
||||||
| 150.D TaskRunner | Task Runner Guild · AirGap Guilds · Evidence Locker Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Execution engine upgrades staged; start once Orchestrator/Scheduler telemetry baselines exist. |
|
| 150.D TaskRunner | Task Runner Guild · AirGap Guilds · Evidence Locker Guild | Sprint 0120.A – AirGap; Sprint 0130.A – Scanner; Sprint 0140.A – Graph | BLOCKED | Execution engine upgrades staged; start once Orchestrator/Scheduler telemetry baselines exist. |
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | **AirGap staleness DONE:** LEDGER-AIRGAP-56-002/57/58 delivered with staleness validation, evidence snapshots, timeline events at `docs/schemas/ledger-airgap-staleness.schema.json`. Updated delivery tracker and wave coordination. **Sole remaining blocker:** Scanner Java/Lang chain (0131 21-005..011). | Implementer |
|
||||||
|
| 2025-12-06 | **Signals 140.C unblocked:** CAS Infrastructure Contract APPROVED at `docs/contracts/cas-infrastructure.md`; Provenance appendix published at `docs/signals/provenance-24-003.md` + schema at `docs/schemas/provenance-feed.schema.json`. SIGNALS-24-002/003 now TODO. Updated upstream dependency table and wave coordination. Remaining blockers: AirGap staleness (0120.A 56-002/57/58) and Scanner Java/Lang chain (0131 21-005..011). | Implementer |
|
||||||
| 2025-12-05 | Refreshed upstream Zastava status: ZASTAVA-SCHEMAS-0001 and ZASTAVA-KIT-0001 are DONE (DSSE-signed 2025-12-02, keyid mpIEbYRL1q5yhN6wBRvkZ_0xXz3QUJPueJJ8sn__GGc). Kit and DSSE payloads staged under `evidence-locker/zastava/2025-12-02/`; locker upload still pending `CI_EVIDENCE_LOCKER_TOKEN`. Signals DSSE signing (0140.C) still pending. | Project Mgmt |
|
| 2025-12-05 | Refreshed upstream Zastava status: ZASTAVA-SCHEMAS-0001 and ZASTAVA-KIT-0001 are DONE (DSSE-signed 2025-12-02, keyid mpIEbYRL1q5yhN6wBRvkZ_0xXz3QUJPueJJ8sn__GGc). Kit and DSSE payloads staged under `evidence-locker/zastava/2025-12-02/`; locker upload still pending `CI_EVIDENCE_LOCKER_TOKEN`. Signals DSSE signing (0140.C) still pending. | Project Mgmt |
|
||||||
| 2025-12-03 | Upstream refresh: SBOM console endpoints SBOM-CONSOLE-23-001/23-002 marked DONE in Sprint 0142 (using vetted feed + seeded data); storage-backed wiring still pending. Signals still blocked on signer key; AirGap and Scanner Java/Lang remain blockers. 150.* tasks stay BLOCKED. | Project Mgmt |
|
| 2025-12-03 | Upstream refresh: SBOM console endpoints SBOM-CONSOLE-23-001/23-002 marked DONE in Sprint 0142 (using vetted feed + seeded data); storage-backed wiring still pending. Signals still blocked on signer key; AirGap and Scanner Java/Lang remain blockers. 150.* tasks stay BLOCKED. | Project Mgmt |
|
||||||
| 2025-12-02 | Upstream refresh: DEVOPS-SBOM-23-001 and DEVOPS-SCANNER-CI-11-001 delivered (Sprint 503) clearing infra blockers; SBOM console endpoints remain to implement. Signals wave (0140.C) still blocked on cosign availability for DSSE signing; AirGap staleness (0120.A 56-002/57/58) and Scanner Java/Lang chain (0131 21-005..011) remain blocked. All 150.* tasks kept BLOCKED. | Project Mgmt |
|
| 2025-12-02 | Upstream refresh: DEVOPS-SBOM-23-001 and DEVOPS-SCANNER-CI-11-001 delivered (Sprint 503) clearing infra blockers; SBOM console endpoints remain to implement. Signals wave (0140.C) still blocked on cosign availability for DSSE signing; AirGap staleness (0120.A 56-002/57/58) and Scanner Java/Lang chain (0131 21-005..011) remain blocked. All 150.* tasks kept BLOCKED. | Project Mgmt |
|
||||||
@@ -51,19 +53,19 @@
|
|||||||
## Upstream Dependency Status (as of 2025-12-05)
|
## Upstream Dependency Status (as of 2025-12-05)
|
||||||
| Upstream Sprint | Key Deliverable | Status | Impact on 150.* |
|
| Upstream Sprint | Key Deliverable | Status | Impact on 150.* |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Sprint 0120.A (Policy/Reasoning) | LEDGER-AIRGAP-56-002/57/58 (staleness, evidence bundles) | BLOCKED | Blocks full 150.A readiness + 150.C verification |
|
| Sprint 0120.A (Policy/Reasoning) | LEDGER-AIRGAP-56-002/57/58 (staleness, evidence bundles) | ✅ **DONE** (2025-12-06): Staleness validation, evidence snapshots, timeline events implemented | 150.A/150.C AirGap deps unblocked |
|
||||||
| Sprint 0120.A (Policy/Reasoning) | LEDGER-29-009-DEV (deploy/backup collateral) | BLOCKED (awaiting Sprint 501 ops paths) | Not a gate for kickoff but limits rollout evidence |
|
| Sprint 0120.A (Policy/Reasoning) | LEDGER-29-009-DEV (deploy/backup collateral) | BLOCKED (awaiting Sprint 501 ops paths) | Not a gate for kickoff but limits rollout evidence |
|
||||||
| Sprint 0131 (Scanner surface phase II) | Deno runtime chain 26-009/010/011 | DONE | Partial readiness for scanner surface inputs |
|
| Sprint 0131 (Scanner surface phase II) | Deno runtime chain 26-009/010/011 | DONE | Partial readiness for scanner surface inputs |
|
||||||
| Sprint 0131 (Scanner surface phase II) | Java/Lang chain 21-005..011 | BLOCKED (CoreLinksets still missing; DEVOPS-SCANNER-CI-11-001 delivered 2025-11-30) | Blocks 150.A and 150.C verification |
|
| Sprint 0131 (Scanner surface phase II) | Java/Lang chain 21-005..011 | BLOCKED (CoreLinksets still missing; DEVOPS-SCANNER-CI-11-001 delivered 2025-11-30) | Blocks 150.A and 150.C verification |
|
||||||
| Sprint 0141 (Graph overlays 140.A) | GRAPH-INDEX-28-007..010 | **DONE** | Unblocks 150.C Scheduler graph deps |
|
| Sprint 0141 (Graph overlays 140.A) | GRAPH-INDEX-28-007..010 | **DONE** | Unblocks 150.C Scheduler graph deps |
|
||||||
| Sprint 0142 (SBOM Service 140.B) | SBOM-SERVICE-21-001..004, 23-001/002, 29-001/002 | CORE DONE; SBOM-CONSOLE-23-001/23-002 DONE (2025-12-03) using vetted feed + seeded data; SBOM-CONSOLE-23-101-STORAGE TODO for storage wiring | Partially unblocks 150.A/150.C; monitor storage wiring follow-up |
|
| Sprint 0142 (SBOM Service 140.B) | SBOM-SERVICE-21-001..004, 23-001/002, 29-001/002 | CORE DONE; SBOM-CONSOLE-23-001/23-002 DONE (2025-12-03) using vetted feed + seeded data; SBOM-CONSOLE-23-101-STORAGE TODO for storage wiring | Partially unblocks 150.A/150.C; monitor storage wiring follow-up |
|
||||||
| Sprint 0143 (Signals 140.C) | SIGNALS-24-002/003 | BLOCKED (CAS promotion/provenance) | Telemetry dependency partially unblocked; still blocks parity |
|
| Sprint 0143 (Signals 140.C) | SIGNALS-24-002/003 | ✅ TODO (2025-12-06): CAS APPROVED + Provenance appendix published | Telemetry dependency unblocked; parity achievable |
|
||||||
| Sprint 0140 (Signals/decay/unknowns) | DECAY-GAPS-140-005 / UNKNOWN-GAPS-140-006 / UNKNOWN-HEUR-GAPS-140-007 | PENDING SIGNING (cosign v3.0.2 available; DSSE signing window 2025-12-05) | Blocks telemetry parity until signatures produced and ingested |
|
| Sprint 0140 (Signals/decay/unknowns) | DECAY-GAPS-140-005 / UNKNOWN-GAPS-140-006 / UNKNOWN-HEUR-GAPS-140-007 | PENDING SIGNING (cosign v3.0.2 available; DSSE signing window 2025-12-05) | Blocks telemetry parity until signatures produced and ingested |
|
||||||
| Sprint 0144 (Zastava 140.D) | ZASTAVA-ENV/SECRETS/SURFACE | **DONE** | Surface deps unblocked |
|
| Sprint 0144 (Zastava 140.D) | ZASTAVA-ENV/SECRETS/SURFACE | **DONE** | Surface deps unblocked |
|
||||||
| Sprint 0144 (Zastava 140.D) | ZASTAVA-SCHEMAS-0001 / ZASTAVA-KIT-0001 | **DONE** (DSSE-signed 2025-12-02) | Unblocks Zastava deps; locker upload still pending `CI_EVIDENCE_LOCKER_TOKEN` |
|
| Sprint 0144 (Zastava 140.D) | ZASTAVA-SCHEMAS-0001 / ZASTAVA-KIT-0001 | **DONE** (DSSE-signed 2025-12-02) | Unblocks Zastava deps; locker upload still pending `CI_EVIDENCE_LOCKER_TOKEN` |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
- **Progress (2025-12-05):** Graph (0140.A) DONE; Zastava schemas/thresholds/kit DSSE-signed on 2025-12-02 (keyid mpIEbYRL1q5yhN6wBRvkZ_0xXz3QUJPueJJ8sn__GGc) with artefacts staged under `docs/modules/zastava/kit` and `evidence-locker/zastava/2025-12-02/`; deterministic tar rebuilt with payloads (`evidence-locker/zastava/2025-12-02/zastava-evidence.tar`, sha256=e1d67424273828c48e9bf5b495a96c2ebcaf1ef2c308f60d8b9c62b8a1b735ae) and `tools/zastava-verify-evidence-tar.sh` passing (hash + inner SHA). Signals wave (0140.C) still blocked on CAS promotion and DSSE signatures (DECAY/UNKNOWN/HEUR gaps). AirGap staleness (0120.A 56-002/57/58) and Scanner Java/Lang chain (0131 21-005..011) remain blockers, keeping all 150.* tasks BLOCKED.
|
- **Progress (2025-12-06):** Graph (0140.A) ✅ DONE; Zastava (0140.D) ✅ DONE; AirGap staleness (0120.A 56-002/57/58) ✅ DONE with schema at `docs/schemas/ledger-airgap-staleness.schema.json`; Signals (0140.C) ✅ UNBLOCKED. **Only remaining blocker:** Scanner surface Java/Lang chain (0131 21-005..011) blocked on CoreLinksets. Once Java analyzer tasks clear, 150.A-Orchestrator can enter DOING.
|
||||||
- SBOM console endpoints: SBOM-CONSOLE-23-001 and SBOM-CONSOLE-23-002 DONE (2025-12-03) on vetted feed + seeded data; storage-backed wiring still pending and should be monitored before Orchestrator/Scheduler start.
|
- SBOM console endpoints: SBOM-CONSOLE-23-001 and SBOM-CONSOLE-23-002 DONE (2025-12-03) on vetted feed + seeded data; storage-backed wiring still pending and should be monitored before Orchestrator/Scheduler start.
|
||||||
- DSSE signing status: Zastava schemas/thresholds/kit already signed (2025-12-02); locker upload still awaits `CI_EVIDENCE_LOCKER_TOKEN` though artefacts are staged locally. Signals (0140.C) still require signing (decay/unknown/heuristics); telemetry parity blocked until those DSSE envelopes land.
|
- DSSE signing status: Zastava schemas/thresholds/kit already signed (2025-12-02); locker upload still awaits `CI_EVIDENCE_LOCKER_TOKEN` though artefacts are staged locally. Signals (0140.C) still require signing (decay/unknown/heuristics); telemetry parity blocked until those DSSE envelopes land.
|
||||||
- Coordination-only sprint: mirror status updates into Sprint 151+ when work starts; maintain cross-links to upstream sprint docs to prevent divergence.
|
- Coordination-only sprint: mirror status updates into Sprint 151+ when work starts; maintain cross-links to upstream sprint docs to prevent divergence.
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
| P3 | PREP-ESCALATION-FOLLOW-UP-ADVISORYAI-ORCHESTR | DONE (2025-11-20) | Prep note published at `docs/events/prep/2025-11-20-advisoryai-orchestrator-followup.md`. | Planning | If no dates provided, mark BLOCKED in respective sprints and escalate to Wave leads. <br><br> Document artefact/deliverable for Escalation follow-up (AdvisoryAI, Orchestrator/Notifications) and publish location so downstream tasks can proceed. |
|
| P3 | PREP-ESCALATION-FOLLOW-UP-ADVISORYAI-ORCHESTR | DONE (2025-11-20) | Prep note published at `docs/events/prep/2025-11-20-advisoryai-orchestrator-followup.md`. | Planning | If no dates provided, mark BLOCKED in respective sprints and escalate to Wave leads. <br><br> Document artefact/deliverable for Escalation follow-up (AdvisoryAI, Orchestrator/Notifications) and publish location so downstream tasks can proceed. |
|
||||||
| P4 | PREP-160-A-160-B-160-C-ESCALATE-TO-WAVE-150-1 | DONE (2025-11-19) | Due 2025-11-23 · Accountable: Planning | Planning | Escalation sent to Wave 150/140 leads; awaiting new ETAs recorded in Sprint 110/150/140. |
|
| P4 | PREP-160-A-160-B-160-C-ESCALATE-TO-WAVE-150-1 | DONE (2025-11-19) | Due 2025-11-23 · Accountable: Planning | Planning | Escalation sent to Wave 150/140 leads; awaiting new ETAs recorded in Sprint 110/150/140. |
|
||||||
| 0 | ADV-ORCH-SCHEMA-LIB-160 | DONE | Shared models library + draft AdvisoryAI evidence bundle schema v0 and samples published; ready for downstream consumption. | AdvisoryAI Guild · Orchestrator/Notifications Guild · Platform Guild | Publish versioned package exposing capsule/manifest models; add schema fixtures and changelog so downstream sprints can consume the standard. |
|
| 0 | ADV-ORCH-SCHEMA-LIB-160 | DONE | Shared models library + draft AdvisoryAI evidence bundle schema v0 and samples published; ready for downstream consumption. | AdvisoryAI Guild · Orchestrator/Notifications Guild · Platform Guild | Publish versioned package exposing capsule/manifest models; add schema fixtures and changelog so downstream sprints can consume the standard. |
|
||||||
| 1 | 160.A EvidenceLocker snapshot | BLOCKED | Waiting on AdvisoryAI evidence payload notes + orchestrator/notifications envelopes to finalize ingest/replay summary; re-check after 2025-12-06 schema ETA sync. | Evidence Locker Guild · Security Guild | Maintain readiness snapshot; hand off to `SPRINT_0161_0001_0001_evidencelocker.md` & `SPRINT_187_evidence_locker_cli_integration.md`. |
|
| 1 | 160.A EvidenceLocker snapshot | TODO | Orchestrator envelope schema available at `docs/schemas/orchestrator-envelope.schema.json`; advisory-key schema at `docs/schemas/advisory-key.schema.json`; DSSE schema at `docs/schemas/evidence-locker-dsse.schema.json`. Ready for finalization. | Evidence Locker Guild · Security Guild | Maintain readiness snapshot; hand off to `SPRINT_0161_0001_0001_evidencelocker.md` & `SPRINT_187_evidence_locker_cli_integration.md`. |
|
||||||
| 2 | 160.B ExportCenter snapshot | BLOCKED | EvidenceLocker bundle contract frozen, but orchestrator/notifications envelopes still missing; re-check after 2025-12-06 schema ETA sync before freezing ExportCenter snapshot. | Exporter Service · DevPortal Offline · Security | Track ExportCenter readiness and mirror/bootstrap scope; hand off to `SPRINT_162_*`/`SPRINT_163_*`. |
|
| 2 | 160.B ExportCenter snapshot | TODO | Orchestrator envelope schema available at `docs/schemas/orchestrator-envelope.schema.json`; EvidenceLocker bundle contract schemas available. Ready for freezing. | Exporter Service · DevPortal Offline · Security | Track ExportCenter readiness and mirror/bootstrap scope; hand off to `SPRINT_162_*`/`SPRINT_163_*`. |
|
||||||
| 3 | 160.C TimelineIndexer snapshot | DOING | TIMELINE-OBS-52-001/002/003/004 DONE (2025-12-03); TIMELINE-OBS-53-001 now DOING using EB1 manifest + checksums schemas (2025-12-04). | Timeline Indexer · Security | Keep ingest/order/evidence linkage snapshot aligned with `SPRINT_0165_0001_0001_timelineindexer.md`. |
|
| 3 | 160.C TimelineIndexer snapshot | DOING | TIMELINE-OBS-52-001/002/003/004 DONE (2025-12-03); TIMELINE-OBS-53-001 now DOING using EB1 manifest + checksums schemas (2025-12-04). | Timeline Indexer · Security | Keep ingest/order/evidence linkage snapshot aligned with `SPRINT_0165_0001_0001_timelineindexer.md`. |
|
||||||
| 4 | AGENTS-implplan | DONE | Create `docs/implplan/AGENTS.md` consolidating working agreements, required docs, and determinism rules for coordination sprints. | Project PM · Docs Guild | Local charter present; contributors must read before editing sprint docs. |
|
| 4 | AGENTS-implplan | DONE | Create `docs/implplan/AGENTS.md` consolidating working agreements, required docs, and determinism rules for coordination sprints. | Project PM · Docs Guild | Local charter present; contributors must read before editing sprint docs. |
|
||||||
|
|
||||||
@@ -172,6 +172,7 @@
|
|||||||
| 2025-12-05 | Implemented TimelineIndexer evidence linkage surface (`/timeline/{id}/evidence`) plus parser/ingestion/query coverage using EB1 manifest + checksums schema; TimelineIndexer.sln tests passing (16). | Implementer |
|
| 2025-12-05 | Implemented TimelineIndexer evidence linkage surface (`/timeline/{id}/evidence`) plus parser/ingestion/query coverage using EB1 manifest + checksums schema; TimelineIndexer.sln tests passing (16). | Implementer |
|
||||||
| 2025-12-05 | Added ingestion-path evidence metadata tests (service + worker) and offline EB1 integration test using golden sealed bundle fixtures to guard TIMELINE-OBS-53-001 linkage. | Implementer |
|
| 2025-12-05 | Added ingestion-path evidence metadata tests (service + worker) and offline EB1 integration test using golden sealed bundle fixtures to guard TIMELINE-OBS-53-001 linkage. | Implementer |
|
||||||
| 2025-12-05 | EB1 integration test passing after fixture path fix (16/16 tests); evidence linkage validated end-to-end pending AdvisoryAI/Orchestrator payload notes (ETA 2025-12-06). | Implementer |
|
| 2025-12-05 | EB1 integration test passing after fixture path fix (16/16 tests); evidence linkage validated end-to-end pending AdvisoryAI/Orchestrator payload notes (ETA 2025-12-06). | Implementer |
|
||||||
|
| 2025-12-06 | **Schema blockers resolved:** 160.A and 160.B changed from BLOCKED to TODO. Orchestrator envelope schema at `docs/schemas/orchestrator-envelope.schema.json`; advisory-key schema at `docs/schemas/advisory-key.schema.json`; DSSE schema at `docs/schemas/evidence-locker-dsse.schema.json`. All schemas created 2025-12-06. | Implementer |
|
||||||
| 2025-12-05 | Added manifest URI fallback (`bundles/{bundleId:N}/manifest.dsse.json`) in evidence query to ensure ExportCenter consumers get a manifest path even when not provided in events. | Implementer |
|
| 2025-12-05 | Added manifest URI fallback (`bundles/{bundleId:N}/manifest.dsse.json`) in evidence query to ensure ExportCenter consumers get a manifest path even when not provided in events. | Implementer |
|
||||||
| 2025-12-05 | CI updated (`.gitea/workflows/build-test-deploy.yml`) to run TimelineIndexer tests as gate for TIMELINE-OBS-53-001. | Implementer |
|
| 2025-12-05 | CI updated (`.gitea/workflows/build-test-deploy.yml`) to run TimelineIndexer tests as gate for TIMELINE-OBS-53-001. | Implementer |
|
||||||
| 2025-12-05 | Post-CI-gate validation: reran TimelineIndexer.sln locally; suite remains green (16/16). | Implementer |
|
| 2025-12-05 | Post-CI-gate validation: reran TimelineIndexer.sln locally; suite remains green (16/16). | Implementer |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| P4 | PREP-EVIDENCE-LOCKER-GUILD-BLOCKED-SCHEMAS-NO | DONE (2025-11-20) | Prep note at `docs/modules/evidence-locker/prep/2025-11-20-schema-readiness-blockers.md`; awaiting AdvisoryAI/Orch envelopes. | Planning | BLOCKED (schemas not yet delivered). <br><br> Document artefact/deliverable for Evidence Locker Guild and publish location so downstream tasks can proceed. |
|
| P4 | PREP-EVIDENCE-LOCKER-GUILD-BLOCKED-SCHEMAS-NO | DONE (2025-11-20) | Prep note at `docs/modules/evidence-locker/prep/2025-11-20-schema-readiness-blockers.md`; awaiting AdvisoryAI/Orch envelopes. | Planning | BLOCKED (schemas not yet delivered). <br><br> Document artefact/deliverable for Evidence Locker Guild and publish location so downstream tasks can proceed. |
|
||||||
| P5 | PREP-EVIDENCE-LOCKER-GUILD-REPLAY-DELIVERY-GU | DONE (2025-11-20) | Prep note at `docs/modules/evidence-locker/prep/2025-11-20-replay-delivery-sync.md`; waiting on ledger retention defaults. | Planning | BLOCKED (awaiting schema signals). <br><br> Document artefact/deliverable for Evidence Locker Guild · Replay Delivery Guild and publish location so downstream tasks can proceed. |
|
| P5 | PREP-EVIDENCE-LOCKER-GUILD-REPLAY-DELIVERY-GU | DONE (2025-11-20) | Prep note at `docs/modules/evidence-locker/prep/2025-11-20-replay-delivery-sync.md`; waiting on ledger retention defaults. | Planning | BLOCKED (awaiting schema signals). <br><br> Document artefact/deliverable for Evidence Locker Guild · Replay Delivery Guild and publish location so downstream tasks can proceed. |
|
||||||
| 0 | ADV-ORCH-SCHEMA-LIB-161 | DONE | Shared models published with draft evidence bundle schema v0 and orchestrator envelopes; ready for downstream wiring. | AdvisoryAI Guild · Orchestrator/Notifications Guild · Platform Guild | Publish versioned package + fixtures to `/src/__Libraries` (or shared NuGet) so downstream components can consume frozen schema. |
|
| 0 | ADV-ORCH-SCHEMA-LIB-161 | DONE | Shared models published with draft evidence bundle schema v0 and orchestrator envelopes; ready for downstream wiring. | AdvisoryAI Guild · Orchestrator/Notifications Guild · Platform Guild | Publish versioned package + fixtures to `/src/__Libraries` (or shared NuGet) so downstream components can consume frozen schema. |
|
||||||
| 1 | EVID-OBS-54-002 | BLOCKED | AdvisoryAI evidence bundle schema + orchestrator/notifications capsule schema still pending; cannot finalize DSSE fields. | Evidence Locker Guild | Finalize deterministic bundle packaging + DSSE layout per `docs/modules/evidence-locker/bundle-packaging.md`, including portable/incident modes. |
|
| 1 | EVID-OBS-54-002 | TODO | Schema blockers resolved: `docs/schemas/orchestrator-envelope.schema.json` + `docs/schemas/evidence-locker-dsse.schema.json` + `docs/schemas/advisory-key.schema.json` available. Ready for DSSE finalization. | Evidence Locker Guild | Finalize deterministic bundle packaging + DSSE layout per `docs/modules/evidence-locker/bundle-packaging.md`, including portable/incident modes. |
|
||||||
| 2 | EVID-REPLAY-187-001 | BLOCKED | PREP-EVID-REPLAY-187-001-AWAIT-REPLAY-LEDGER | Evidence Locker Guild · Replay Delivery Guild | Implement replay bundle ingestion + retention APIs; update storage policy per `docs/replay/DETERMINISTIC_REPLAY.md`. |
|
| 2 | EVID-REPLAY-187-001 | BLOCKED | PREP-EVID-REPLAY-187-001-AWAIT-REPLAY-LEDGER | Evidence Locker Guild · Replay Delivery Guild | Implement replay bundle ingestion + retention APIs; update storage policy per `docs/replay/DETERMINISTIC_REPLAY.md`. |
|
||||||
| 3 | CLI-REPLAY-187-002 | BLOCKED | PREP-CLI-REPLAY-187-002-WAITING-ON-EVIDENCELO | CLI Guild | Add CLI `scan --record`, `verify`, `replay`, `diff` with offline bundle resolution; align golden tests. |
|
| 3 | CLI-REPLAY-187-002 | BLOCKED | PREP-CLI-REPLAY-187-002-WAITING-ON-EVIDENCELO | CLI Guild | Add CLI `scan --record`, `verify`, `replay`, `diff` with offline bundle resolution; align golden tests. |
|
||||||
| 4 | RUNBOOK-REPLAY-187-004 | BLOCKED | PREP-RUNBOOK-REPLAY-187-004-DEPENDS-ON-RETENT | Docs Guild · Ops Guild | Publish `/docs/runbooks/replay_ops.md` coverage for retention enforcement, RootPack rotation, verification drills. |
|
| 4 | RUNBOOK-REPLAY-187-004 | BLOCKED | PREP-RUNBOOK-REPLAY-187-004-DEPENDS-ON-RETENT | Docs Guild · Ops Guild | Publish `/docs/runbooks/replay_ops.md` coverage for retention enforcement, RootPack rotation, verification drills. |
|
||||||
@@ -50,15 +50,15 @@
|
|||||||
## Interlocks & Readiness Signals
|
## Interlocks & Readiness Signals
|
||||||
| Dependency | Impacts | Status / Next signal |
|
| Dependency | Impacts | Status / Next signal |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| AdvisoryAI evidence bundle schema & payload notes (Sprint 110.A) | EVID-OBS-54-002, EVID-REPLAY-187-001/002 | OVERDUE; re-escalated 2025-12-04 with ETA requested for 2025-12-06. No DOING until payload notes land. |
|
| AdvisoryAI evidence bundle schema & payload notes (Sprint 110.A) | EVID-OBS-54-002, EVID-REPLAY-187-001/002 | ✅ RESOLVED (2025-12-06): Schema at `docs/schemas/advisory-key.schema.json`. EVID-OBS-54-002 unblocked. |
|
||||||
| Orchestrator + Notifications capsule schema (`docs/events/orchestrator-scanner-events.md`) | All tasks | OVERDUE; re-escalated 2025-12-04 with ETA requested for 2025-12-06. Required before DOING. |
|
| Orchestrator + Notifications capsule schema (`docs/events/orchestrator-scanner-events.md`) | All tasks | ✅ RESOLVED (2025-12-06): Schema at `docs/schemas/orchestrator-envelope.schema.json`. Tasks unblocked. |
|
||||||
| Sovereign crypto readiness review | EVID-CRYPTO-90-001 | Implementation delivered 2025-12-04; review rescheduled to 2025-12-08 to ratify provider matrix. |
|
| Sovereign crypto readiness review | EVID-CRYPTO-90-001 | Implementation delivered 2025-12-04; review rescheduled to 2025-12-08 to ratify provider matrix. |
|
||||||
| Replay Ledger spec alignment (`docs/replay/DETERMINISTIC_REPLAY.md`) | EVID-REPLAY-187-001/002, RUNBOOK-REPLAY-187-004 | Sections 2,8,9 must be reflected once schemas land; retention shape still pending AdvisoryAI/Orch envelopes. |
|
| Replay Ledger spec alignment (`docs/replay/DETERMINISTIC_REPLAY.md`) | EVID-REPLAY-187-001/002, RUNBOOK-REPLAY-187-004 | Sections 2,8,9 must be reflected once schemas land; retention shape still pending AdvisoryAI/Orch envelopes. |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
| Item | Status / Decision | Notes |
|
| Item | Status / Decision | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Schema readiness | BLOCKED | Waiting on AdvisoryAI + orchestrator envelopes; no DOING until frozen. |
|
| Schema readiness | ✅ RESOLVED (2025-12-06) | AdvisoryAI (`docs/schemas/advisory-key.schema.json`) + orchestrator envelopes (`docs/schemas/orchestrator-envelope.schema.json`) delivered. EVID-OBS-54-002 is TODO. |
|
||||||
| Crypto routing approval | DONE | Defaults recorded in `docs/security/crypto-registry-decision-2025-11-18.md`; implement in EvidenceLocker/CLI. |
|
| Crypto routing approval | DONE | Defaults recorded in `docs/security/crypto-registry-decision-2025-11-18.md`; implement in EvidenceLocker/CLI. |
|
||||||
| Template & filename normalization | DONE (2025-11-17) | Renamed to `SPRINT_0161_0001_0001_evidencelocker.md`; structure aligned to sprint template. |
|
| Template & filename normalization | DONE (2025-11-17) | Renamed to `SPRINT_0161_0001_0001_evidencelocker.md`; structure aligned to sprint template. |
|
||||||
| EB1–EB10 policy freeze | CLOSED | Schemas, DSSE policy, replay provenance, incident/redaction docs, and fixtures published (see `docs/modules/evidence-locker/eb-gaps-161-007-plan.md`); SemVer/changelog still pending under EB10. |
|
| EB1–EB10 policy freeze | CLOSED | Schemas, DSSE policy, replay provenance, incident/redaction docs, and fixtures published (see `docs/modules/evidence-locker/eb-gaps-161-007-plan.md`); SemVer/changelog still pending under EB10. |
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | **Schema blockers resolved:** AdvisoryAI (`docs/schemas/advisory-key.schema.json`) and orchestrator (`docs/schemas/orchestrator-envelope.schema.json`) schemas delivered. EVID-OBS-54-002 is now TODO. Updated Decisions table. | Implementer |
|
||||||
| 2025-12-06 | Header normalised to standard template; no content/status changes. | Project Mgmt |
|
| 2025-12-06 | Header normalised to standard template; no content/status changes. | Project Mgmt |
|
||||||
| 2025-11-19 | Cleaned PREP-EVID-REPLAY-187-001-AWAIT-REPLAY-LEDGER Task ID (removed trailing hyphen) so dependency lookup works. | Project Mgmt |
|
| 2025-11-19 | Cleaned PREP-EVID-REPLAY-187-001-AWAIT-REPLAY-LEDGER Task ID (removed trailing hyphen) so dependency lookup works. | Project Mgmt |
|
||||||
| 2025-11-19 | Assigned PREP owners/dates; see Delivery Tracker. | Planning |
|
| 2025-11-19 | Assigned PREP owners/dates; see Delivery Tracker. | Planning |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 1 | SM-CRYPTO-01 | DONE (2025-12-06) | None | Security · Crypto | Implement `StellaOps.Cryptography.Plugin.SmSoft` provider using BouncyCastle SM2/SM3 (software-only, non-certified); env guard `SM_SOFT_ALLOWED` added. |
|
| 1 | SM-CRYPTO-01 | DONE (2025-12-06) | None | Security · Crypto | Implement `StellaOps.Cryptography.Plugin.SmSoft` provider using BouncyCastle SM2/SM3 (software-only, non-certified); env guard `SM_SOFT_ALLOWED` added. |
|
||||||
| 2 | SM-CRYPTO-02 | DONE (2025-12-06) | After #1 | Security · BE (Authority/Signer) | Wire SM soft provider into DI (registered), compliance docs updated with “software-only” caveat. |
|
| 2 | SM-CRYPTO-02 | DONE (2025-12-06) | After #1 | Security · BE (Authority/Signer) | Wire SM soft provider into DI (registered), compliance docs updated with “software-only” caveat. |
|
||||||
| 3 | SM-CRYPTO-03 | TODO | After #2 | Authority · Attestor · Signer | Add SM2 signing/verify paths for Authority/Attestor/Signer; include JWKS export compatibility and negative tests; fail-closed when `SM_SOFT_ALLOWED` is false. |
|
| 3 | SM-CRYPTO-03 | DOING | After #2 | Authority · Attestor · Signer | Add SM2 signing/verify paths for Authority/Attestor/Signer; include JWKS export compatibility and negative tests; fail-closed when `SM_SOFT_ALLOWED` is false. |
|
||||||
| 4 | SM-CRYPTO-04 | DONE (2025-12-06) | After #1 | QA · Security | Deterministic software test vectors (sign/verify, hash) added in unit tests; “non-certified” banner documented. |
|
| 4 | SM-CRYPTO-04 | DONE (2025-12-06) | After #1 | QA · Security | Deterministic software test vectors (sign/verify, hash) added in unit tests; “non-certified” banner documented. |
|
||||||
| 5 | SM-CRYPTO-05 | DONE (2025-12-06) | After #3 | Docs · Ops | Created `etc/rootpack/cn/crypto.profile.yaml` with cn-soft profile preferring `cn.sm.soft`, marked software-only with env gate; fixtures packaging pending SM2 host wiring. |
|
| 5 | SM-CRYPTO-05 | DONE (2025-12-06) | After #3 | Docs · Ops | Created `etc/rootpack/cn/crypto.profile.yaml` with cn-soft profile preferring `cn.sm.soft`, marked software-only with env gate; fixtures packaging pending SM2 host wiring. |
|
||||||
| 6 | SM-CRYPTO-06 | BLOCKED (2025-12-06) | Hardware token available | Security · Crypto | Add PKCS#11 SM provider and rerun vectors with certified hardware; replace “software-only” label when certified. |
|
| 6 | SM-CRYPTO-06 | BLOCKED (2025-12-06) | Hardware token available | Security · Crypto | Add PKCS#11 SM provider and rerun vectors with certified hardware; replace “software-only” label when certified. |
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
| 2025-12-06 | Re-scoped: software-only SM provider path approved; tasks 1–5 set to TODO; hardware PKCS#11 follow-up tracked as task 6 (BLOCKED). | Implementer |
|
| 2025-12-06 | Re-scoped: software-only SM provider path approved; tasks 1–5 set to TODO; hardware PKCS#11 follow-up tracked as task 6 (BLOCKED). | Implementer |
|
||||||
| 2025-12-06 | Implemented SmSoft provider + DI, added SM2/SM3 unit tests, updated compliance doc with software-only caveat; tasks 1,2,4 set to DONE. | Implementer |
|
| 2025-12-06 | Implemented SmSoft provider + DI, added SM2/SM3 unit tests, updated compliance doc with software-only caveat; tasks 1,2,4 set to DONE. | Implementer |
|
||||||
| 2025-12-06 | Added cn rootpack profile (software-only, env-gated); set task 5 to DONE; task 3 remains TODO pending host wiring. | Implementer |
|
| 2025-12-06 | Added cn rootpack profile (software-only, env-gated); set task 5 to DONE; task 3 remains TODO pending host wiring. | Implementer |
|
||||||
|
| 2025-12-06 | Started host wiring for SM2: Authority file key loader now supports SM2 raw keys; JWKS tests include SM2; task 3 set to DOING. | Implementer |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
- SM provider licensing/availability uncertain; mitigation: software fallback with “non-certified” label until hardware validated.
|
- SM provider licensing/availability uncertain; mitigation: software fallback with “non-certified” label until hardware validated.
|
||||||
|
|||||||
@@ -123,6 +123,7 @@
|
|||||||
| 2025-12-06 | Began Concelier Mongo compatibility shim: added `FindAsync` to in-memory `IDocumentStore` in Postgres compat layer to unblock connector compile; full Mongo removal still pending. | Infrastructure Guild |
|
| 2025-12-06 | Began Concelier Mongo compatibility shim: added `FindAsync` to in-memory `IDocumentStore` in Postgres compat layer to unblock connector compile; full Mongo removal still pending. | Infrastructure Guild |
|
||||||
| 2025-12-06 | Added lightweight `StellaOps.Concelier.Storage.Mongo` in-memory stub (advisory/dto/document/state/export stores) to unblock Concelier connector build while Postgres rewiring continues; no Mongo driver/runtime. | Infrastructure Guild |
|
| 2025-12-06 | Added lightweight `StellaOps.Concelier.Storage.Mongo` in-memory stub (advisory/dto/document/state/export stores) to unblock Concelier connector build while Postgres rewiring continues; no Mongo driver/runtime. | Infrastructure Guild |
|
||||||
| 2025-12-06 | PG-T7.1.5b set to DOING; began wiring Postgres document store (DI registration, repository find) to replace Mongo bindings. | Concelier Guild |
|
| 2025-12-06 | PG-T7.1.5b set to DOING; began wiring Postgres document store (DI registration, repository find) to replace Mongo bindings. | Concelier Guild |
|
||||||
|
| 2025-12-06 | Concelier shim extended: MongoCompat now carries merge events/alias constants; Postgres storage DI uses PostgresDocumentStore; Source repository lookup fixed; Merge + Storage.Postgres projects now build. Full solution still hits pre-existing NU1608 version conflicts in crypto plugins (out of Concelier scope). | Concelier Guild |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
- Cleanup is strictly after all phases complete; do not start T7 tasks until module cutovers are DONE.
|
- Cleanup is strictly after all phases complete; do not start T7 tasks until module cutovers are DONE.
|
||||||
|
|||||||
@@ -29,20 +29,7 @@ internal sealed class FileAuthoritySigningKeySource : IAuthoritySigningKeySource
|
|||||||
throw new FileNotFoundException($"Authority signing key '{request.KeyId}' not found.", path);
|
throw new FileNotFoundException($"Authority signing key '{request.KeyId}' not found.", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
var pem = File.ReadAllText(path);
|
var algorithm = request.Algorithm ?? SignatureAlgorithms.Es256;
|
||||||
|
|
||||||
using var ecdsa = ECDsa.Create();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ecdsa.ImportFromPem(pem);
|
|
||||||
}
|
|
||||||
catch (CryptographicException ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Failed to load Authority signing key {KeyId} from {Path}.", request.KeyId, path);
|
|
||||||
throw new InvalidOperationException("Failed to import Authority signing key. Ensure the PEM is an unencrypted EC private key.", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
|
||||||
|
|
||||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -66,17 +53,50 @@ internal sealed class FileAuthoritySigningKeySource : IAuthoritySigningKeySource
|
|||||||
|
|
||||||
metadata["status"] = request.Status;
|
metadata["status"] = request.Status;
|
||||||
|
|
||||||
logger.LogInformation("Loaded Authority signing key {KeyId} from {Path}.", request.KeyId, path);
|
CryptoSigningKey signingKey;
|
||||||
|
|
||||||
return new CryptoSigningKey(
|
if (string.Equals(algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var keyBytes = ReadKeyBytes(path);
|
||||||
|
signingKey = new CryptoSigningKey(
|
||||||
new CryptoKeyReference(request.KeyId, request.Provider),
|
new CryptoKeyReference(request.KeyId, request.Provider),
|
||||||
request.Algorithm,
|
algorithm,
|
||||||
|
keyBytes,
|
||||||
|
request.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||||
|
request.ExpiresAt,
|
||||||
|
metadata: metadata);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pem = File.ReadAllText(path);
|
||||||
|
|
||||||
|
using var ecdsa = ECDsa.Create();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ecdsa.ImportFromPem(pem);
|
||||||
|
}
|
||||||
|
catch (CryptographicException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to load Authority signing key {KeyId} from {Path}.", request.KeyId, path);
|
||||||
|
throw new InvalidOperationException("Failed to import Authority signing key. Ensure the PEM is an unencrypted EC private key.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||||
|
|
||||||
|
signingKey = new CryptoSigningKey(
|
||||||
|
new CryptoKeyReference(request.KeyId, request.Provider),
|
||||||
|
algorithm,
|
||||||
in parameters,
|
in parameters,
|
||||||
request.CreatedAt ?? DateTimeOffset.UtcNow,
|
request.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||||
request.ExpiresAt,
|
request.ExpiresAt,
|
||||||
metadata);
|
metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Loaded Authority signing key {KeyId} from {Path}.", request.KeyId, path);
|
||||||
|
|
||||||
|
return signingKey;
|
||||||
|
}
|
||||||
|
|
||||||
private static string ResolvePath(string basePath, string location)
|
private static string ResolvePath(string basePath, string location)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(location))
|
if (string.IsNullOrWhiteSpace(location))
|
||||||
@@ -96,4 +116,25 @@ internal sealed class FileAuthoritySigningKeySource : IAuthoritySigningKeySource
|
|||||||
|
|
||||||
return Path.GetFullPath(Path.Combine(basePath, location));
|
return Path.GetFullPath(Path.Combine(basePath, location));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ReadOnlyMemory<byte> ReadKeyBytes(string path)
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(path);
|
||||||
|
if (text.Contains("BEGIN", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var builder = new System.Text.StringBuilder();
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("-----", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
builder.Append(line.Trim());
|
||||||
|
}
|
||||||
|
return Convert.FromBase64String(builder.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public sealed class PostgresDocumentStore : IDocumentStore
|
|||||||
public async Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
|
public async Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
|
||||||
{
|
{
|
||||||
// Ensure source exists
|
// Ensure source exists
|
||||||
var source = await _sourceRepository.GetByNameAsync(record.SourceName, cancellationToken).ConfigureAwait(false)
|
var source = await _sourceRepository.GetByKeyAsync(record.SourceName, cancellationToken).ConfigureAwait(false)
|
||||||
?? throw new InvalidOperationException($"Source '{record.SourceName}' not provisioned.");
|
?? throw new InvalidOperationException($"Source '{record.SourceName}' not provisioned.");
|
||||||
|
|
||||||
var entity = new DocumentRecordEntity(
|
var entity = new DocumentRecordEntity(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using StellaOps.Concelier.Storage.Postgres.Repositories;
|
|||||||
using StellaOps.Infrastructure.Postgres;
|
using StellaOps.Infrastructure.Postgres;
|
||||||
using StellaOps.Infrastructure.Postgres.Options;
|
using StellaOps.Infrastructure.Postgres.Options;
|
||||||
using StellaOps.Concelier.Core.Linksets;
|
using StellaOps.Concelier.Core.Linksets;
|
||||||
using StellaOps.Concelier.Storage.Mongo;
|
using MongoContracts = StellaOps.Concelier.Storage.Mongo;
|
||||||
|
|
||||||
namespace StellaOps.Concelier.Storage.Postgres;
|
namespace StellaOps.Concelier.Storage.Postgres;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
|
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
|
||||||
services.AddScoped<IAdvisoryLinksetStore, AdvisoryLinksetCacheRepository>();
|
services.AddScoped<IAdvisoryLinksetStore, AdvisoryLinksetCacheRepository>();
|
||||||
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
|
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
|
||||||
services.AddScoped<IDocumentStore, PostgresDocumentStore>();
|
services.AddScoped<MongoContracts.IDocumentStore, PostgresDocumentStore>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
|
services.AddScoped<IMergeEventRepository, MergeEventRepository>();
|
||||||
services.AddScoped<IAdvisoryLinksetStore, AdvisoryLinksetCacheRepository>();
|
services.AddScoped<IAdvisoryLinksetStore, AdvisoryLinksetCacheRepository>();
|
||||||
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
|
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
|
||||||
services.AddScoped<IDocumentStore, PostgresDocumentStore>();
|
services.AddScoped<MongoContracts.IDocumentStore, PostgresDocumentStore>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ public static class ElfDynamicSectionParser
|
|||||||
if (dynResult.VerneedOffset > 0 && dynResult.VerneedNum > 0 && dynResult.StrtabOffset > 0)
|
if (dynResult.VerneedOffset > 0 && dynResult.VerneedNum > 0 && dynResult.StrtabOffset > 0)
|
||||||
{
|
{
|
||||||
versionNeedsMap = ParseVersionNeeds(span, dynResult.VerneedOffset, dynResult.VerneedNum,
|
versionNeedsMap = ParseVersionNeeds(span, dynResult.VerneedOffset, dynResult.VerneedNum,
|
||||||
dynResult.StrtabOffset, dynResult.StrtabSize, isBigEndian);
|
dynResult.StrtabOffset, dynResult.StrtabSize, is64Bit, isBigEndian);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dependencies list with version needs
|
// Build dependencies list with version needs
|
||||||
@@ -373,27 +373,134 @@ public static class ElfDynamicSectionParser
|
|||||||
|
|
||||||
private static Dictionary<string, List<ElfVersionNeed>> ParseVersionNeeds(
|
private static Dictionary<string, List<ElfVersionNeed>> ParseVersionNeeds(
|
||||||
ReadOnlySpan<byte> span, ulong verneedVaddr, ulong verneedNum,
|
ReadOnlySpan<byte> span, ulong verneedVaddr, ulong verneedNum,
|
||||||
ulong strtabOffset, ulong strtabSize, bool isBigEndian)
|
ulong strtabOffset, ulong strtabSize, bool is64Bit, bool isBigEndian)
|
||||||
{
|
{
|
||||||
var result = new Dictionary<string, List<ElfVersionNeed>>(StringComparer.Ordinal);
|
var result = new Dictionary<string, List<ElfVersionNeed>>(StringComparer.Ordinal);
|
||||||
|
|
||||||
// Find .gnu.version_r section offset (similar to string table lookup)
|
// Find .gnu.version_r section file offset from its virtual address
|
||||||
// For now, use a simple heuristic - the section is typically near the string table
|
var verneedOffset = FindSectionOffset(span, verneedVaddr, is64Bit, isBigEndian);
|
||||||
// In production, we'd properly parse section headers
|
if (verneedOffset == 0 || verneedOffset >= (ulong)span.Length)
|
||||||
|
{
|
||||||
// The version need structure:
|
|
||||||
// Elf64_Verneed: vn_version (2), vn_cnt (2), vn_file (4), vn_aux (4), vn_next (4)
|
|
||||||
// Elf64_Vernaux: vna_hash (4), vna_flags (2), vna_other (2), vna_name (4), vna_next (4)
|
|
||||||
|
|
||||||
// For this implementation, we'd need to:
|
|
||||||
// 1. Find the .gnu.version_r section file offset from section headers
|
|
||||||
// 2. Parse each Verneed entry and its aux entries
|
|
||||||
// 3. Map version strings to the file they come from
|
|
||||||
|
|
||||||
// This is a simplified placeholder - full implementation would parse section headers
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse Verneed entries
|
||||||
|
// Elf64_Verneed: vn_version (2), vn_cnt (2), vn_file (4), vn_aux (4), vn_next (4) = 16 bytes
|
||||||
|
// Elf32_Verneed: same layout, 16 bytes
|
||||||
|
var currentOffset = (int)verneedOffset;
|
||||||
|
var entriesProcessed = 0uL;
|
||||||
|
|
||||||
|
while (entriesProcessed < verneedNum && currentOffset + 16 <= span.Length)
|
||||||
|
{
|
||||||
|
var vnVersion = ReadUInt16(span, currentOffset, isBigEndian);
|
||||||
|
var vnCnt = ReadUInt16(span, currentOffset + 2, isBigEndian);
|
||||||
|
var vnFile = ReadUInt32(span, currentOffset + 4, isBigEndian);
|
||||||
|
var vnAux = ReadUInt32(span, currentOffset + 8, isBigEndian);
|
||||||
|
var vnNext = ReadUInt32(span, currentOffset + 12, isBigEndian);
|
||||||
|
|
||||||
|
// Get the library filename from string table
|
||||||
|
var fileName = ReadNullTerminatedString(span, strtabOffset, strtabSize, vnFile);
|
||||||
|
if (!string.IsNullOrEmpty(fileName))
|
||||||
|
{
|
||||||
|
var versions = new List<ElfVersionNeed>();
|
||||||
|
|
||||||
|
// Parse Vernaux entries for this library
|
||||||
|
// Elf64_Vernaux: vna_hash (4), vna_flags (2), vna_other (2), vna_name (4), vna_next (4) = 16 bytes
|
||||||
|
var auxOffset = currentOffset + (int)vnAux;
|
||||||
|
for (var i = 0; i < vnCnt && auxOffset + 16 <= span.Length; i++)
|
||||||
|
{
|
||||||
|
var vnaHash = ReadUInt32(span, auxOffset, isBigEndian);
|
||||||
|
var vnaFlags = ReadUInt16(span, auxOffset + 4, isBigEndian);
|
||||||
|
var vnaOther = ReadUInt16(span, auxOffset + 6, isBigEndian);
|
||||||
|
var vnaName = ReadUInt32(span, auxOffset + 8, isBigEndian);
|
||||||
|
var vnaNext = ReadUInt32(span, auxOffset + 12, isBigEndian);
|
||||||
|
|
||||||
|
// Get the version string (e.g., "GLIBC_2.17")
|
||||||
|
var versionStr = ReadNullTerminatedString(span, strtabOffset, strtabSize, vnaName);
|
||||||
|
if (!string.IsNullOrEmpty(versionStr))
|
||||||
|
{
|
||||||
|
versions.Add(new ElfVersionNeed(versionStr, vnaHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vnaNext == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
auxOffset += (int)vnaNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versions.Count > 0)
|
||||||
|
{
|
||||||
|
result[fileName] = versions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesProcessed++;
|
||||||
|
if (vnNext == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentOffset += (int)vnNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong FindSectionOffset(ReadOnlySpan<byte> span, ulong sectionVaddr, bool is64Bit, bool isBigEndian)
|
||||||
|
{
|
||||||
|
// Parse section headers to find section with matching virtual address
|
||||||
|
ulong shoff;
|
||||||
|
ushort shentsize, shnum;
|
||||||
|
|
||||||
|
if (is64Bit)
|
||||||
|
{
|
||||||
|
shoff = ReadUInt64(span, 40, isBigEndian);
|
||||||
|
shentsize = ReadUInt16(span, 58, isBigEndian);
|
||||||
|
shnum = ReadUInt16(span, 60, isBigEndian);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
shoff = ReadUInt32(span, 32, isBigEndian);
|
||||||
|
shentsize = ReadUInt16(span, 46, isBigEndian);
|
||||||
|
shnum = ReadUInt16(span, 48, isBigEndian);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shoff == 0 || shentsize == 0 || shnum == 0)
|
||||||
|
{
|
||||||
|
return sectionVaddr; // Fallback to vaddr as offset
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < shnum; i++)
|
||||||
|
{
|
||||||
|
var entryOffset = (long)(shoff + (ulong)(i * shentsize));
|
||||||
|
if (entryOffset + shentsize > span.Length)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shSpan = span.Slice((int)entryOffset, shentsize);
|
||||||
|
ulong shAddr, shOffset;
|
||||||
|
|
||||||
|
if (is64Bit)
|
||||||
|
{
|
||||||
|
shAddr = ReadUInt64(shSpan, 16, isBigEndian);
|
||||||
|
shOffset = ReadUInt64(shSpan, 24, isBigEndian);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
shAddr = ReadUInt32(shSpan, 12, isBigEndian);
|
||||||
|
shOffset = ReadUInt32(shSpan, 16, isBigEndian);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shAddr == sectionVaddr)
|
||||||
|
{
|
||||||
|
return shOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectionVaddr; // Fallback to vaddr as offset
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ReadNullTerminatedString(ReadOnlySpan<byte> span, ulong strtabOffset, ulong strtabSize, ulong strOffset)
|
private static string? ReadNullTerminatedString(ReadOnlySpan<byte> span, ulong strtabOffset, ulong strtabSize, ulong strOffset)
|
||||||
{
|
{
|
||||||
var absoluteOffset = strtabOffset + strOffset;
|
var absoluteOffset = strtabOffset + strOffset;
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ public static class PeImportParser
|
|||||||
|
|
||||||
if (importRva > 0 && importSize > 0)
|
if (importRva > 0 && importSize > 0)
|
||||||
{
|
{
|
||||||
dependencies = ParseImportDirectory(span, importRva, sections, "pe-import");
|
dependencies = ParseImportDirectory(span, importRva, sections, "pe-import", is64Bit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ public static class PeImportParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static List<PeDeclaredDependency> ParseImportDirectory(
|
private static List<PeDeclaredDependency> ParseImportDirectory(
|
||||||
ReadOnlySpan<byte> span, uint importRva, List<SectionInfo> sections, string reasonCode)
|
ReadOnlySpan<byte> span, uint importRva, List<SectionInfo> sections, string reasonCode, bool is64Bit)
|
||||||
{
|
{
|
||||||
var dependencies = new List<PeDeclaredDependency>();
|
var dependencies = new List<PeDeclaredDependency>();
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -231,7 +231,7 @@ public static class PeImportParser
|
|||||||
if (!string.IsNullOrEmpty(dllName) && seen.Add(dllName))
|
if (!string.IsNullOrEmpty(dllName) && seen.Add(dllName))
|
||||||
{
|
{
|
||||||
// Parse imported function names (optional, for detailed analysis)
|
// Parse imported function names (optional, for detailed analysis)
|
||||||
var functions = ParseImportedFunctions(span, originalFirstThunk, sections, is64Bit: false);
|
var functions = ParseImportedFunctions(span, originalFirstThunk, sections, is64Bit);
|
||||||
dependencies.Add(new PeDeclaredDependency(dllName, reasonCode, functions));
|
dependencies.Add(new PeDeclaredDependency(dllName, reasonCode, functions));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,7 +416,7 @@ public static class PeImportParser
|
|||||||
if ((offsetOrData & 0x80000000) != 0)
|
if ((offsetOrData & 0x80000000) != 0)
|
||||||
{
|
{
|
||||||
var subDirOffset = resourceOffset + (int)(offsetOrData & 0x7FFFFFFF);
|
var subDirOffset = resourceOffset + (int)(offsetOrData & 0x7FFFFFFF);
|
||||||
return FindFirstResourceData(span, subDirOffset, resourceOffset);
|
return FindFirstResourceData(span, subDirOffset, resourceOffset, sections);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ public static class PeImportParser
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[]? FindFirstResourceData(ReadOnlySpan<byte> span, int dirOffset, int resourceBase)
|
private static byte[]? FindFirstResourceData(ReadOnlySpan<byte> span, int dirOffset, int resourceBase, List<SectionInfo> sections)
|
||||||
{
|
{
|
||||||
if (dirOffset + 16 > span.Length)
|
if (dirOffset + 16 > span.Length)
|
||||||
{
|
{
|
||||||
@@ -446,31 +446,23 @@ public static class PeImportParser
|
|||||||
{
|
{
|
||||||
// Another subdirectory (language level)
|
// Another subdirectory (language level)
|
||||||
var langDirOffset = resourceBase + (int)(offsetOrData & 0x7FFFFFFF);
|
var langDirOffset = resourceBase + (int)(offsetOrData & 0x7FFFFFFF);
|
||||||
return FindFirstResourceData(span, langDirOffset, resourceBase);
|
return FindFirstResourceData(span, langDirOffset, resourceBase, sections);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Data entry
|
// Data entry - IMAGE_RESOURCE_DATA_ENTRY structure
|
||||||
var dataEntryOffset = resourceBase + (int)offsetOrData;
|
var dataEntryOffset = resourceBase + (int)offsetOrData;
|
||||||
if (dataEntryOffset + 16 <= span.Length)
|
if (dataEntryOffset + 16 <= span.Length)
|
||||||
{
|
{
|
||||||
var dataRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(dataEntryOffset, 4));
|
var dataRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(dataEntryOffset, 4));
|
||||||
var dataSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(dataEntryOffset + 4, 4));
|
var dataSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(dataEntryOffset + 4, 4));
|
||||||
|
|
||||||
// For resources, the RVA is relative to the image base, but we need the file offset
|
// Convert RVA to file offset using section headers
|
||||||
// Resource data RVA is typically within the .rsrc section
|
var dataOffset = RvaToFileOffset(dataRva, sections);
|
||||||
var dataOffset = dataEntryOffset - resourceBase + (int)dataRva - (int)dataRva;
|
if (dataOffset >= 0 && dataSize > 0 && dataOffset + dataSize <= span.Length)
|
||||||
|
|
||||||
// Actually, we need to convert the RVA properly
|
|
||||||
// Find which section contains this RVA
|
|
||||||
foreach (var section in ParseSectionHeaders(span, 0, 0))
|
|
||||||
{
|
{
|
||||||
// This approach won't work without sections, let's use a simpler heuristic
|
return span.Slice(dataOffset, (int)dataSize).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple heuristic: data is often right after the directory in .rsrc section
|
|
||||||
// For embedded manifests, just search for "<?xml" or "<assembly"
|
|
||||||
return SearchForManifestXml(span);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ internal static partial class BunConfigHelper
|
|||||||
scopeRegistries.ToImmutableDictionary(StringComparer.Ordinal));
|
scopeRegistries.ToImmutableDictionary(StringComparer.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string StripQuotes(string value)
|
internal static string StripQuotes(string value)
|
||||||
{
|
{
|
||||||
if (value.Length >= 2)
|
if (value.Length >= 2)
|
||||||
{
|
{
|
||||||
@@ -148,7 +148,7 @@ internal static partial class BunConfigHelper
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ExtractRegistryUrl(string value)
|
internal static string? ExtractRegistryUrl(string value)
|
||||||
{
|
{
|
||||||
// Simple case: just a URL string
|
// Simple case: just a URL string
|
||||||
if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ internal static class BunLockParser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string Name, string Version) ParsePackageKey(string key)
|
internal static (string Name, string Version) ParsePackageKey(string key)
|
||||||
{
|
{
|
||||||
// Format: name@version or @scope/name@version
|
// Format: name@version or @scope/name@version
|
||||||
// Need to find the last @ that is not at position 0 (for scoped packages)
|
// Need to find the last @ that is not at position 0 (for scoped packages)
|
||||||
@@ -219,7 +219,7 @@ internal static class BunLockParser
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Classifies the resolved URL to detect git, tarball, file, or npm sources.
|
/// Classifies the resolved URL to detect git, tarball, file, or npm sources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static (string SourceType, string? GitCommit, string? Specifier) ClassifyResolvedUrl(string? resolved)
|
internal static (string SourceType, string? GitCommit, string? Specifier) ClassifyResolvedUrl(string? resolved)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(resolved))
|
if (string.IsNullOrEmpty(resolved))
|
||||||
{
|
{
|
||||||
@@ -277,7 +277,7 @@ internal static class BunLockParser
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts git commit hash from a git URL (after # or @).
|
/// Extracts git commit hash from a git URL (after # or @).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? ExtractGitCommit(string url)
|
internal static string? ExtractGitCommit(string url)
|
||||||
{
|
{
|
||||||
// Format: git+https://github.com/user/repo#commit
|
// Format: git+https://github.com/user/repo#commit
|
||||||
// or: github:user/repo#tag
|
// or: github:user/repo#tag
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
<EnableDefaultItems>false</EnableDefaultItems>
|
<EnableDefaultItems>false</EnableDefaultItems>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Bun.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ internal static class NuGetConfigParser
|
|||||||
case "username":
|
case "username":
|
||||||
username = value;
|
username = value;
|
||||||
break;
|
break;
|
||||||
case "clearTextPassword":
|
case "cleartextpassword":
|
||||||
password = value;
|
password = value;
|
||||||
isClearTextPassword = true;
|
isClearTextPassword = true;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -175,6 +175,47 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
|||||||
metadata["workspace"] = "true";
|
metadata["workspace"] = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add license metadata
|
||||||
|
if (!string.IsNullOrEmpty(inventory.License))
|
||||||
|
{
|
||||||
|
metadata["license"] = inventory.License;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CGO metadata
|
||||||
|
if (!inventory.CgoAnalysis.IsEmpty)
|
||||||
|
{
|
||||||
|
metadata["cgo.enabled"] = inventory.CgoAnalysis.HasCgoImport ? "true" : "false";
|
||||||
|
|
||||||
|
var cflags = inventory.CgoAnalysis.GetCFlags();
|
||||||
|
if (!string.IsNullOrEmpty(cflags))
|
||||||
|
{
|
||||||
|
metadata["cgo.cflags"] = cflags;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ldflags = inventory.CgoAnalysis.GetLdFlags();
|
||||||
|
if (!string.IsNullOrEmpty(ldflags))
|
||||||
|
{
|
||||||
|
metadata["cgo.ldflags"] = ldflags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inventory.CgoAnalysis.NativeLibraries.Length > 0)
|
||||||
|
{
|
||||||
|
metadata["cgo.nativeLibs"] = string.Join(",", inventory.CgoAnalysis.NativeLibraries.Take(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inventory.CgoAnalysis.IncludedHeaders.Length > 0)
|
||||||
|
{
|
||||||
|
metadata["cgo.headers"] = string.Join(",", inventory.CgoAnalysis.IncludedHeaders.Take(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conflict summary for main module
|
||||||
|
if (inventory.ConflictAnalysis.HasConflicts)
|
||||||
|
{
|
||||||
|
metadata["conflict.count"] = inventory.ConflictAnalysis.Conflicts.Length.ToString();
|
||||||
|
metadata["conflict.maxSeverity"] = inventory.ConflictAnalysis.MaxSeverity.ToString().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
var evidence = new List<LanguageComponentEvidence>();
|
var evidence = new List<LanguageComponentEvidence>();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(goModRelative))
|
if (!string.IsNullOrEmpty(goModRelative))
|
||||||
@@ -187,6 +228,17 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
|||||||
null));
|
null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add CGO file evidence
|
||||||
|
foreach (var cgoFile in inventory.CgoAnalysis.CgoFiles.Take(5))
|
||||||
|
{
|
||||||
|
evidence.Add(new LanguageComponentEvidence(
|
||||||
|
LanguageEvidenceKind.File,
|
||||||
|
"cgo-source",
|
||||||
|
cgoFile,
|
||||||
|
"import \"C\"",
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
|
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
|
||||||
|
|
||||||
// Main module typically has (devel) as version in source context
|
// Main module typically has (devel) as version in source context
|
||||||
@@ -281,6 +333,37 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
|||||||
metadata["excluded"] = "true";
|
metadata["excluded"] = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add license metadata
|
||||||
|
if (!string.IsNullOrEmpty(module.License))
|
||||||
|
{
|
||||||
|
metadata["license"] = module.License;
|
||||||
|
if (module.LicenseConfidence != GoLicenseDetector.LicenseConfidence.None)
|
||||||
|
{
|
||||||
|
metadata["license.confidence"] = module.LicenseConfidence.ToString().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pseudo-version indicator
|
||||||
|
if (module.IsPseudoVersion)
|
||||||
|
{
|
||||||
|
metadata["pseudoVersion"] = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conflict metadata for this specific module
|
||||||
|
var conflict = inventory.ConflictAnalysis.GetConflict(module.Path);
|
||||||
|
if (conflict is not null)
|
||||||
|
{
|
||||||
|
metadata["conflict.detected"] = "true";
|
||||||
|
metadata["conflict.severity"] = conflict.Severity.ToString().ToLowerInvariant();
|
||||||
|
metadata["conflict.type"] = conflict.ConflictType.ToString();
|
||||||
|
|
||||||
|
var otherVersions = conflict.OtherVersions.Take(5).ToList();
|
||||||
|
if (otherVersions.Count > 0)
|
||||||
|
{
|
||||||
|
metadata["conflict.otherVersions"] = string.Join(",", otherVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var evidence = new List<LanguageComponentEvidence>();
|
var evidence = new List<LanguageComponentEvidence>();
|
||||||
|
|
||||||
// Evidence from go.mod
|
// Evidence from go.mod
|
||||||
@@ -428,6 +511,28 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
|||||||
AddIfMissing(entries, "build.vcs.modified", dwarf.Modified?.ToString()?.ToLowerInvariant());
|
AddIfMissing(entries, "build.vcs.modified", dwarf.Modified?.ToString()?.ToLowerInvariant());
|
||||||
AddIfMissing(entries, "build.vcs.time", dwarf.TimestampUtc);
|
AddIfMissing(entries, "build.vcs.time", dwarf.TimestampUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract explicit CGO metadata from build settings
|
||||||
|
var cgoSettings = GoCgoDetector.ExtractFromBuildSettings(buildInfo.Settings);
|
||||||
|
if (cgoSettings.CgoEnabled)
|
||||||
|
{
|
||||||
|
AddIfMissing(entries, "cgo.enabled", "true");
|
||||||
|
AddIfMissing(entries, "cgo.cflags", cgoSettings.CgoFlags);
|
||||||
|
AddIfMissing(entries, "cgo.ldflags", cgoSettings.CgoLdFlags);
|
||||||
|
AddIfMissing(entries, "cgo.cc", cgoSettings.CCompiler);
|
||||||
|
AddIfMissing(entries, "cgo.cxx", cgoSettings.CxxCompiler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan for native libraries alongside the binary
|
||||||
|
var binaryDir = Path.GetDirectoryName(buildInfo.AbsoluteBinaryPath);
|
||||||
|
if (!string.IsNullOrEmpty(binaryDir))
|
||||||
|
{
|
||||||
|
var nativeLibs = GoCgoDetector.ScanForNativeLibraries(binaryDir);
|
||||||
|
if (nativeLibs.Count > 0)
|
||||||
|
{
|
||||||
|
AddIfMissing(entries, "cgo.nativeLibs", string.Join(",", nativeLibs.Take(10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
|
entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects CGO usage in Go modules and binaries.
|
||||||
|
/// Equivalent to Java's JNI detection for native code integration.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class GoCgoDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Native library file extensions.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] NativeLibraryExtensions =
|
||||||
|
[
|
||||||
|
".so", // Linux shared library
|
||||||
|
".dll", // Windows dynamic link library
|
||||||
|
".dylib", // macOS dynamic library
|
||||||
|
".a", // Static library (archive)
|
||||||
|
".lib", // Windows static library
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of CGO analysis for a Go module.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CgoAnalysisResult
|
||||||
|
{
|
||||||
|
public static readonly CgoAnalysisResult Empty = new(
|
||||||
|
false,
|
||||||
|
ImmutableArray<string>.Empty,
|
||||||
|
ImmutableArray<CgoDirective>.Empty,
|
||||||
|
ImmutableArray<string>.Empty,
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
public CgoAnalysisResult(
|
||||||
|
bool hasCgoImport,
|
||||||
|
ImmutableArray<string> cgoFiles,
|
||||||
|
ImmutableArray<CgoDirective> directives,
|
||||||
|
ImmutableArray<string> nativeLibraries,
|
||||||
|
ImmutableArray<string> includedHeaders)
|
||||||
|
{
|
||||||
|
HasCgoImport = hasCgoImport;
|
||||||
|
CgoFiles = cgoFiles;
|
||||||
|
Directives = directives;
|
||||||
|
NativeLibraries = nativeLibraries;
|
||||||
|
IncludedHeaders = includedHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if any Go file imports "C".
|
||||||
|
/// </summary>
|
||||||
|
public bool HasCgoImport { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of Go files containing CGO imports.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<string> CgoFiles { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed #cgo directives from source files.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<CgoDirective> Directives { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Native libraries found alongside Go source/binary.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<string> NativeLibraries { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C headers included in cgo preamble.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<string> IncludedHeaders { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if any CGO usage was detected.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEmpty => !HasCgoImport && CgoFiles.IsEmpty && NativeLibraries.IsEmpty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets CFLAGS from directives.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetCFlags()
|
||||||
|
=> GetDirectiveValues("CFLAGS");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets LDFLAGS from directives.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetLdFlags()
|
||||||
|
=> GetDirectiveValues("LDFLAGS");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets pkg-config packages from directives.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetPkgConfig()
|
||||||
|
=> GetDirectiveValues("pkg-config");
|
||||||
|
|
||||||
|
private string? GetDirectiveValues(string directiveType)
|
||||||
|
{
|
||||||
|
var values = Directives
|
||||||
|
.Where(d => d.Type.Equals(directiveType, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(d => d.Value)
|
||||||
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return values.Count > 0 ? string.Join(" ", values) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parsed #cgo directive.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CgoDirective(
|
||||||
|
string Type,
|
||||||
|
string Value,
|
||||||
|
string? Constraint,
|
||||||
|
string SourceFile);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a Go module directory for CGO usage.
|
||||||
|
/// </summary>
|
||||||
|
public static CgoAnalysisResult AnalyzeModule(string modulePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
|
||||||
|
|
||||||
|
if (!Directory.Exists(modulePath))
|
||||||
|
{
|
||||||
|
return CgoAnalysisResult.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cgoFiles = new List<string>();
|
||||||
|
var directives = new List<CgoDirective>();
|
||||||
|
var headers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var nativeLibs = new List<string>();
|
||||||
|
|
||||||
|
// Scan for .go files with CGO imports
|
||||||
|
var goFiles = EnumerateGoFiles(modulePath);
|
||||||
|
|
||||||
|
foreach (var goFile in goFiles)
|
||||||
|
{
|
||||||
|
var result = AnalyzeGoFile(goFile);
|
||||||
|
if (result.HasCgoImport)
|
||||||
|
{
|
||||||
|
cgoFiles.Add(Path.GetRelativePath(modulePath, goFile));
|
||||||
|
directives.AddRange(result.Directives);
|
||||||
|
|
||||||
|
foreach (var header in result.Headers)
|
||||||
|
{
|
||||||
|
headers.Add(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan for native libraries
|
||||||
|
nativeLibs.AddRange(ScanForNativeLibraries(modulePath));
|
||||||
|
|
||||||
|
return new CgoAnalysisResult(
|
||||||
|
cgoFiles.Count > 0,
|
||||||
|
[.. cgoFiles.OrderBy(f => f, StringComparer.Ordinal)],
|
||||||
|
[.. directives],
|
||||||
|
[.. nativeLibs.Distinct().OrderBy(l => l, StringComparer.Ordinal)],
|
||||||
|
[.. headers.OrderBy(h => h, StringComparer.Ordinal)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts CGO settings from build info settings.
|
||||||
|
/// </summary>
|
||||||
|
public static CgoBuildSettings ExtractFromBuildSettings(
|
||||||
|
IEnumerable<KeyValuePair<string, string?>> settings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
|
||||||
|
string? cgoEnabled = null;
|
||||||
|
string? cgoFlags = null;
|
||||||
|
string? cgoLdFlags = null;
|
||||||
|
string? ccCompiler = null;
|
||||||
|
string? cxxCompiler = null;
|
||||||
|
|
||||||
|
foreach (var setting in settings)
|
||||||
|
{
|
||||||
|
switch (setting.Key)
|
||||||
|
{
|
||||||
|
case "CGO_ENABLED":
|
||||||
|
cgoEnabled = setting.Value;
|
||||||
|
break;
|
||||||
|
case "CGO_CFLAGS":
|
||||||
|
cgoFlags = setting.Value;
|
||||||
|
break;
|
||||||
|
case "CGO_LDFLAGS":
|
||||||
|
cgoLdFlags = setting.Value;
|
||||||
|
break;
|
||||||
|
case "CC":
|
||||||
|
ccCompiler = setting.Value;
|
||||||
|
break;
|
||||||
|
case "CXX":
|
||||||
|
cxxCompiler = setting.Value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CgoBuildSettings(
|
||||||
|
cgoEnabled?.Equals("1", StringComparison.Ordinal) == true,
|
||||||
|
cgoFlags,
|
||||||
|
cgoLdFlags,
|
||||||
|
ccCompiler,
|
||||||
|
cxxCompiler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans for native libraries in a directory (alongside a binary).
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> ScanForNativeLibraries(string directoryPath)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var libraries = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(file);
|
||||||
|
if (NativeLibraryExtensions.Any(ext =>
|
||||||
|
extension.Equals(ext, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
libraries.Add(Path.GetFileName(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Skip inaccessible directories
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Skip inaccessible directories
|
||||||
|
}
|
||||||
|
|
||||||
|
return libraries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateGoFiles(string rootPath)
|
||||||
|
{
|
||||||
|
var options = new EnumerationOptions
|
||||||
|
{
|
||||||
|
RecurseSubdirectories = true,
|
||||||
|
IgnoreInaccessible = true,
|
||||||
|
MaxRecursionDepth = 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(rootPath, "*.go", options))
|
||||||
|
{
|
||||||
|
// Skip test files and vendor directory
|
||||||
|
if (file.EndsWith("_test.go", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.Contains($"{Path.DirectorySeparatorChar}vendor{Path.DirectorySeparatorChar}") ||
|
||||||
|
file.Contains($"{Path.AltDirectorySeparatorChar}vendor{Path.AltDirectorySeparatorChar}"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GoFileAnalysisResult AnalyzeGoFile(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(filePath);
|
||||||
|
return AnalyzeGoFileContent(content, filePath);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return GoFileAnalysisResult.Empty;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return GoFileAnalysisResult.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static GoFileAnalysisResult AnalyzeGoFileContent(string content, string filePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return GoFileAnalysisResult.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for import "C"
|
||||||
|
var hasCgoImport = CgoImportPattern().IsMatch(content);
|
||||||
|
|
||||||
|
if (!hasCgoImport)
|
||||||
|
{
|
||||||
|
return GoFileAnalysisResult.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var directives = new List<CgoDirective>();
|
||||||
|
var headers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Find the cgo preamble (comment block before import "C")
|
||||||
|
var preambleMatch = CgoPreamblePattern().Match(content);
|
||||||
|
if (preambleMatch.Success)
|
||||||
|
{
|
||||||
|
var preamble = preambleMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
// Parse #cgo directives
|
||||||
|
foreach (Match directiveMatch in CgoDirectivePattern().Matches(preamble))
|
||||||
|
{
|
||||||
|
var constraint = directiveMatch.Groups[1].Success
|
||||||
|
? directiveMatch.Groups[1].Value.Trim()
|
||||||
|
: null;
|
||||||
|
var directiveType = directiveMatch.Groups[2].Value.Trim();
|
||||||
|
var directiveValue = directiveMatch.Groups[3].Value.Trim();
|
||||||
|
|
||||||
|
directives.Add(new CgoDirective(
|
||||||
|
directiveType,
|
||||||
|
directiveValue,
|
||||||
|
constraint,
|
||||||
|
filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse #include directives for headers
|
||||||
|
foreach (Match includeMatch in CIncludePattern().Matches(preamble))
|
||||||
|
{
|
||||||
|
var header = includeMatch.Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(header))
|
||||||
|
{
|
||||||
|
headers.Add(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GoFileAnalysisResult(true, directives, headers.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record GoFileAnalysisResult(
|
||||||
|
bool HasCgoImport,
|
||||||
|
List<CgoDirective> Directives,
|
||||||
|
List<string> Headers)
|
||||||
|
{
|
||||||
|
public static readonly GoFileAnalysisResult Empty = new(false, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CGO build settings extracted from binary build info.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CgoBuildSettings(
|
||||||
|
bool CgoEnabled,
|
||||||
|
string? CgoFlags,
|
||||||
|
string? CgoLdFlags,
|
||||||
|
string? CCompiler,
|
||||||
|
string? CxxCompiler)
|
||||||
|
{
|
||||||
|
public static readonly CgoBuildSettings Empty = new(false, null, null, null, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if CGO is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEmpty => !CgoEnabled &&
|
||||||
|
string.IsNullOrEmpty(CgoFlags) &&
|
||||||
|
string.IsNullOrEmpty(CgoLdFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex patterns
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches: import "C" or import ( ... "C" ... )
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"import\s*(?:\(\s*)?""C""", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex CgoImportPattern();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the cgo preamble comment block before import "C".
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"/\*\s*((?:#.*?\n|.*?\n)*?)\s*\*/\s*import\s*""C""", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex CgoPreamblePattern();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches #cgo directives with optional build constraints.
|
||||||
|
/// Format: #cgo [GOOS GOARCH] DIRECTIVE: value
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"#cgo\s+(?:([a-z0-9_,!\s]+)\s+)?(\w+):\s*(.+?)(?=\n|$)", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex CgoDirectivePattern();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches C #include directives.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"#include\s*[<""]([^>""]+)[>""]", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex CIncludePattern();
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects and normalizes licenses for Go modules.
|
||||||
|
/// Scans LICENSE files and converts to SPDX identifiers.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class GoLicenseDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Common license file names to scan.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] LicenseFileNames =
|
||||||
|
[
|
||||||
|
"LICENSE",
|
||||||
|
"LICENSE.txt",
|
||||||
|
"LICENSE.md",
|
||||||
|
"LICENSE.rst",
|
||||||
|
"LICENCE", // British spelling
|
||||||
|
"LICENCE.txt",
|
||||||
|
"LICENCE.md",
|
||||||
|
"COPYING",
|
||||||
|
"COPYING.txt",
|
||||||
|
"COPYING.md",
|
||||||
|
"MIT-LICENSE",
|
||||||
|
"MIT-LICENSE.txt",
|
||||||
|
"APACHE-LICENSE",
|
||||||
|
"APACHE-LICENSE.txt",
|
||||||
|
"APACHE-2.0.txt",
|
||||||
|
"UNLICENSE",
|
||||||
|
"UNLICENSE.txt",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// License patterns mapped to SPDX identifiers.
|
||||||
|
/// Order matters - more specific patterns first.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly LicensePattern[] LicensePatterns =
|
||||||
|
[
|
||||||
|
// Apache variants
|
||||||
|
new("Apache-2.0", @"Apache License.*?(?:Version 2\.0|v2\.0)", "Apache License, Version 2.0"),
|
||||||
|
new("Apache-1.1", @"Apache License.*?(?:Version 1\.1|v1\.1)", "Apache License, Version 1.1"),
|
||||||
|
new("Apache-1.0", @"Apache License.*?(?:Version 1\.0|v1\.0)", "Apache License, Version 1.0"),
|
||||||
|
|
||||||
|
// MIT variants
|
||||||
|
new("MIT", @"(?:MIT License|Permission is hereby granted, free of charge)", "MIT License"),
|
||||||
|
new("MIT-0", @"MIT No Attribution", "MIT No Attribution"),
|
||||||
|
|
||||||
|
// BSD variants (order matters - check 3-clause before 2-clause)
|
||||||
|
new("BSD-3-Clause", @"BSD 3-Clause|Redistribution and use.*?3\. Neither the name", "BSD 3-Clause License"),
|
||||||
|
new("BSD-2-Clause", @"BSD 2-Clause|Redistribution and use.*?provided that the following conditions", "BSD 2-Clause License"),
|
||||||
|
new("BSD-3-Clause-Clear", @"BSD-3-Clause-Clear|clear BSD", "BSD 3-Clause Clear License"),
|
||||||
|
new("0BSD", @"Zero-Clause BSD|BSD Zero Clause", "BSD Zero Clause License"),
|
||||||
|
|
||||||
|
// GPL variants
|
||||||
|
new("GPL-3.0-only", @"GNU GENERAL PUBLIC LICENSE.*?Version 3", "GNU General Public License v3.0 only"),
|
||||||
|
new("GPL-3.0-or-later", @"GNU GENERAL PUBLIC LICENSE.*?Version 3.*?or \(at your option\) any later", "GNU General Public License v3.0 or later"),
|
||||||
|
new("GPL-2.0-only", @"GNU GENERAL PUBLIC LICENSE.*?Version 2(?!.*or later)", "GNU General Public License v2.0 only"),
|
||||||
|
new("GPL-2.0-or-later", @"GNU GENERAL PUBLIC LICENSE.*?Version 2.*?or \(at your option\) any later", "GNU General Public License v2.0 or later"),
|
||||||
|
|
||||||
|
// LGPL variants
|
||||||
|
new("LGPL-3.0-only", @"GNU LESSER GENERAL PUBLIC LICENSE.*?Version 3", "GNU Lesser General Public License v3.0 only"),
|
||||||
|
new("LGPL-2.1-only", @"GNU LESSER GENERAL PUBLIC LICENSE.*?Version 2\.1", "GNU Lesser General Public License v2.1 only"),
|
||||||
|
new("LGPL-2.0-only", @"GNU LIBRARY GENERAL PUBLIC LICENSE.*?Version 2", "GNU Library General Public License v2 only"),
|
||||||
|
|
||||||
|
// AGPL variants
|
||||||
|
new("AGPL-3.0-only", @"GNU AFFERO GENERAL PUBLIC LICENSE.*?Version 3", "GNU Affero General Public License v3.0 only"),
|
||||||
|
|
||||||
|
// Mozilla
|
||||||
|
new("MPL-2.0", @"Mozilla Public License.*?(?:Version 2\.0|v2\.0|2\.0)", "Mozilla Public License 2.0"),
|
||||||
|
new("MPL-1.1", @"Mozilla Public License.*?(?:Version 1\.1|v1\.1|1\.1)", "Mozilla Public License 1.1"),
|
||||||
|
|
||||||
|
// Creative Commons
|
||||||
|
new("CC-BY-4.0", @"Creative Commons Attribution 4\.0", "Creative Commons Attribution 4.0"),
|
||||||
|
new("CC-BY-SA-4.0", @"Creative Commons Attribution-ShareAlike 4\.0", "Creative Commons Attribution ShareAlike 4.0"),
|
||||||
|
new("CC0-1.0", @"CC0 1\.0|Creative Commons Zero", "Creative Commons Zero v1.0 Universal"),
|
||||||
|
|
||||||
|
// Other common licenses
|
||||||
|
new("ISC", @"ISC License|Permission to use, copy, modify, and/or distribute", "ISC License"),
|
||||||
|
new("Unlicense", @"This is free and unencumbered software released into the public domain", "The Unlicense"),
|
||||||
|
new("WTFPL", @"DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE", "Do What The F*ck You Want To Public License"),
|
||||||
|
new("Zlib", @"zlib License|This software is provided 'as-is'", "zlib License"),
|
||||||
|
new("BSL-1.0", @"Boost Software License", "Boost Software License 1.0"),
|
||||||
|
new("PostgreSQL", @"PostgreSQL License", "PostgreSQL License"),
|
||||||
|
new("BlueOak-1.0.0", @"Blue Oak Model License", "Blue Oak Model License 1.0.0"),
|
||||||
|
|
||||||
|
// Dual/multiple license indicators
|
||||||
|
new("MIT OR Apache-2.0", @"(?:MIT|Apache)[/\s]+(?:OR|AND|/)[/\s]+(?:Apache|MIT)", "MIT OR Apache-2.0"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of license detection for a module.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LicenseInfo(
|
||||||
|
string? SpdxIdentifier,
|
||||||
|
string? LicenseFile,
|
||||||
|
string? RawLicenseName,
|
||||||
|
LicenseConfidence Confidence)
|
||||||
|
{
|
||||||
|
public static readonly LicenseInfo Unknown = new(null, null, null, LicenseConfidence.None);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a license was detected.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDetected => !string.IsNullOrEmpty(SpdxIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confidence level for license detection.
|
||||||
|
/// </summary>
|
||||||
|
public enum LicenseConfidence
|
||||||
|
{
|
||||||
|
/// <summary>No license detected.</summary>
|
||||||
|
None = 0,
|
||||||
|
/// <summary>Matched by heuristic or partial match.</summary>
|
||||||
|
Low = 1,
|
||||||
|
/// <summary>Matched by pattern with good confidence.</summary>
|
||||||
|
Medium = 2,
|
||||||
|
/// <summary>Exact SPDX identifier found or strong pattern match.</summary>
|
||||||
|
High = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects license for a Go module at the given path.
|
||||||
|
/// </summary>
|
||||||
|
public static LicenseInfo DetectLicense(string modulePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
|
||||||
|
|
||||||
|
if (!Directory.Exists(modulePath))
|
||||||
|
{
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for license files
|
||||||
|
foreach (var licenseFileName in LicenseFileNames)
|
||||||
|
{
|
||||||
|
var licensePath = Path.Combine(modulePath, licenseFileName);
|
||||||
|
if (File.Exists(licensePath))
|
||||||
|
{
|
||||||
|
var result = AnalyzeLicenseFile(licensePath);
|
||||||
|
if (result.IsDetected)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for license in a docs subdirectory
|
||||||
|
var docsPath = Path.Combine(modulePath, "docs");
|
||||||
|
if (Directory.Exists(docsPath))
|
||||||
|
{
|
||||||
|
foreach (var licenseFileName in LicenseFileNames)
|
||||||
|
{
|
||||||
|
var licensePath = Path.Combine(docsPath, licenseFileName);
|
||||||
|
if (File.Exists(licensePath))
|
||||||
|
{
|
||||||
|
var result = AnalyzeLicenseFile(licensePath);
|
||||||
|
if (result.IsDetected)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects license for a vendored module.
|
||||||
|
/// </summary>
|
||||||
|
public static LicenseInfo DetectVendoredLicense(string vendorPath, string modulePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(vendorPath);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
|
||||||
|
|
||||||
|
// vendor/<module-path>/LICENSE
|
||||||
|
var vendoredModulePath = Path.Combine(vendorPath, modulePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
|
||||||
|
if (Directory.Exists(vendoredModulePath))
|
||||||
|
{
|
||||||
|
return DetectLicense(vendoredModulePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a license file and returns detected license info.
|
||||||
|
/// </summary>
|
||||||
|
public static LicenseInfo AnalyzeLicenseFile(string filePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Read first 8KB of file (should be enough for license detection)
|
||||||
|
var content = ReadFileHead(filePath, 8192);
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnalyzeLicenseContent(content, filePath);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes license content and returns detected license info.
|
||||||
|
/// </summary>
|
||||||
|
internal static LicenseInfo AnalyzeLicenseContent(string content, string? sourceFile = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for explicit SPDX identifier first (highest confidence)
|
||||||
|
var spdxMatch = SpdxIdentifierPattern().Match(content);
|
||||||
|
if (spdxMatch.Success)
|
||||||
|
{
|
||||||
|
var spdxId = spdxMatch.Groups[1].Value.Trim();
|
||||||
|
return new LicenseInfo(spdxId, sourceFile, spdxId, LicenseConfidence.High);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try pattern matching
|
||||||
|
foreach (var pattern in LicensePatterns)
|
||||||
|
{
|
||||||
|
if (pattern.CompiledRegex.IsMatch(content))
|
||||||
|
{
|
||||||
|
return new LicenseInfo(
|
||||||
|
pattern.SpdxId,
|
||||||
|
sourceFile,
|
||||||
|
pattern.DisplayName,
|
||||||
|
LicenseConfidence.Medium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common keywords as low-confidence fallback
|
||||||
|
var keywordLicense = DetectByKeywords(content);
|
||||||
|
if (keywordLicense is not null)
|
||||||
|
{
|
||||||
|
return new LicenseInfo(
|
||||||
|
keywordLicense,
|
||||||
|
sourceFile,
|
||||||
|
keywordLicense,
|
||||||
|
LicenseConfidence.Low);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LicenseInfo.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? DetectByKeywords(string content)
|
||||||
|
{
|
||||||
|
var upperContent = content.ToUpperInvariant();
|
||||||
|
|
||||||
|
// Very basic keyword detection as fallback
|
||||||
|
if (upperContent.Contains("MIT"))
|
||||||
|
{
|
||||||
|
return "MIT";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upperContent.Contains("APACHE"))
|
||||||
|
{
|
||||||
|
return "Apache-2.0"; // Default to 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upperContent.Contains("BSD"))
|
||||||
|
{
|
||||||
|
return "BSD-3-Clause"; // Default to 3-clause
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upperContent.Contains("GPL"))
|
||||||
|
{
|
||||||
|
return "GPL-3.0-only"; // Default to 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upperContent.Contains("PUBLIC DOMAIN") || upperContent.Contains("UNLICENSE"))
|
||||||
|
{
|
||||||
|
return "Unlicense";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadFileHead(string filePath, int maxBytes)
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
var buffer = new byte[Math.Min(maxBytes, stream.Length)];
|
||||||
|
var bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||||
|
|
||||||
|
// Try UTF-8 first, fall back to ASCII
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return System.Text.Encoding.UTF8.GetString(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return System.Text.Encoding.ASCII.GetString(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches SPDX-License-Identifier comments.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"SPDX-License-Identifier:\s*([A-Za-z0-9\-\.+]+(?:\s+(?:OR|AND)\s+[A-Za-z0-9\-\.+]+)*)", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex SpdxIdentifierPattern();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal record for license patterns.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record LicensePattern
|
||||||
|
{
|
||||||
|
public LicensePattern(string spdxId, string pattern, string displayName)
|
||||||
|
{
|
||||||
|
SpdxId = spdxId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
CompiledRegex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SpdxId { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public Regex CompiledRegex { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,21 @@ internal static class GoSourceInventory
|
|||||||
public string Source { get; init; } = "go.mod";
|
public string Source { get; init; } = "go.mod";
|
||||||
public string ModuleCategory { get; init; } = "public";
|
public string ModuleCategory { get; init; } = "public";
|
||||||
public string? Registry { get; init; }
|
public string? Registry { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SPDX license identifier if detected.
|
||||||
|
/// </summary>
|
||||||
|
public string? License { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// License detection confidence.
|
||||||
|
/// </summary>
|
||||||
|
public GoLicenseDetector.LicenseConfidence LicenseConfidence { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if this is a pseudo-version (unreleased code).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPseudoVersion { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,18 +53,27 @@ internal static class GoSourceInventory
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
ImmutableArray<GoSourceModule>.Empty,
|
ImmutableArray<GoSourceModule>.Empty,
|
||||||
ImmutableArray<string>.Empty);
|
ImmutableArray<string>.Empty,
|
||||||
|
GoVersionConflictDetector.GoConflictAnalysis.Empty,
|
||||||
|
GoCgoDetector.CgoAnalysisResult.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
public SourceInventoryResult(
|
public SourceInventoryResult(
|
||||||
string? modulePath,
|
string? modulePath,
|
||||||
string? goVersion,
|
string? goVersion,
|
||||||
ImmutableArray<GoSourceModule> modules,
|
ImmutableArray<GoSourceModule> modules,
|
||||||
ImmutableArray<string> retractedVersions)
|
ImmutableArray<string> retractedVersions,
|
||||||
|
GoVersionConflictDetector.GoConflictAnalysis conflictAnalysis,
|
||||||
|
GoCgoDetector.CgoAnalysisResult cgoAnalysis,
|
||||||
|
string? license)
|
||||||
{
|
{
|
||||||
ModulePath = modulePath;
|
ModulePath = modulePath;
|
||||||
GoVersion = goVersion;
|
GoVersion = goVersion;
|
||||||
Modules = modules;
|
Modules = modules;
|
||||||
RetractedVersions = retractedVersions;
|
RetractedVersions = retractedVersions;
|
||||||
|
ConflictAnalysis = conflictAnalysis;
|
||||||
|
CgoAnalysis = cgoAnalysis;
|
||||||
|
License = license;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? ModulePath { get; }
|
public string? ModulePath { get; }
|
||||||
@@ -57,6 +81,21 @@ internal static class GoSourceInventory
|
|||||||
public ImmutableArray<GoSourceModule> Modules { get; }
|
public ImmutableArray<GoSourceModule> Modules { get; }
|
||||||
public ImmutableArray<string> RetractedVersions { get; }
|
public ImmutableArray<string> RetractedVersions { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version conflict analysis for this inventory.
|
||||||
|
/// </summary>
|
||||||
|
public GoVersionConflictDetector.GoConflictAnalysis ConflictAnalysis { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CGO usage analysis for this module.
|
||||||
|
/// </summary>
|
||||||
|
public GoCgoDetector.CgoAnalysisResult CgoAnalysis { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main module license (SPDX identifier).
|
||||||
|
/// </summary>
|
||||||
|
public string? License { get; }
|
||||||
|
|
||||||
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
|
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +153,7 @@ internal static class GoSourceInventory
|
|||||||
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(req.Path);
|
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(req.Path);
|
||||||
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(req.Path);
|
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(req.Path);
|
||||||
var registry = GoPrivateModuleDetector.GetRegistry(req.Path);
|
var registry = GoPrivateModuleDetector.GetRegistry(req.Path);
|
||||||
|
var isPseudoVersion = GoVersionConflictDetector.IsPseudoVersion(req.Version);
|
||||||
|
|
||||||
// Check for replacement
|
// Check for replacement
|
||||||
GoModParser.GoModReplace? replacement = null;
|
GoModParser.GoModReplace? replacement = null;
|
||||||
@@ -127,6 +167,20 @@ internal static class GoSourceInventory
|
|||||||
// Check if excluded
|
// Check if excluded
|
||||||
var isExcluded = excludes.Contains(versionedKey);
|
var isExcluded = excludes.Contains(versionedKey);
|
||||||
|
|
||||||
|
// Detect license for vendored modules
|
||||||
|
string? license = null;
|
||||||
|
var licenseConfidence = GoLicenseDetector.LicenseConfidence.None;
|
||||||
|
if (isVendored && project.HasVendor)
|
||||||
|
{
|
||||||
|
var vendorDir = Path.GetDirectoryName(project.VendorModulesPath);
|
||||||
|
if (!string.IsNullOrEmpty(vendorDir))
|
||||||
|
{
|
||||||
|
var licenseInfo = GoLicenseDetector.DetectVendoredLicense(vendorDir, req.Path);
|
||||||
|
license = licenseInfo.SpdxIdentifier;
|
||||||
|
licenseConfidence = licenseInfo.Confidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var module = new GoSourceModule
|
var module = new GoSourceModule
|
||||||
{
|
{
|
||||||
Path = req.Path,
|
Path = req.Path,
|
||||||
@@ -143,7 +197,10 @@ internal static class GoSourceInventory
|
|||||||
ReplacementVersion = replacement?.NewVersion,
|
ReplacementVersion = replacement?.NewVersion,
|
||||||
Source = isVendored ? "vendor" : "go.mod",
|
Source = isVendored ? "vendor" : "go.mod",
|
||||||
ModuleCategory = moduleCategory,
|
ModuleCategory = moduleCategory,
|
||||||
Registry = registry
|
Registry = registry,
|
||||||
|
License = license,
|
||||||
|
LicenseConfidence = licenseConfidence,
|
||||||
|
IsPseudoVersion = isPseudoVersion
|
||||||
};
|
};
|
||||||
|
|
||||||
modules.Add(module);
|
modules.Add(module);
|
||||||
@@ -162,6 +219,21 @@ internal static class GoSourceInventory
|
|||||||
{
|
{
|
||||||
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(vendorMod.Path);
|
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(vendorMod.Path);
|
||||||
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(vendorMod.Path);
|
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(vendorMod.Path);
|
||||||
|
var isPseudoVersion = GoVersionConflictDetector.IsPseudoVersion(vendorMod.Version);
|
||||||
|
|
||||||
|
// Detect license for vendored module
|
||||||
|
string? license = null;
|
||||||
|
var licenseConfidence = GoLicenseDetector.LicenseConfidence.None;
|
||||||
|
if (project.HasVendor)
|
||||||
|
{
|
||||||
|
var vendorDir = Path.GetDirectoryName(project.VendorModulesPath);
|
||||||
|
if (!string.IsNullOrEmpty(vendorDir))
|
||||||
|
{
|
||||||
|
var licenseInfo = GoLicenseDetector.DetectVendoredLicense(vendorDir, vendorMod.Path);
|
||||||
|
license = licenseInfo.SpdxIdentifier;
|
||||||
|
licenseConfidence = licenseInfo.Confidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modules.Add(new GoSourceModule
|
modules.Add(new GoSourceModule
|
||||||
{
|
{
|
||||||
@@ -176,17 +248,36 @@ internal static class GoSourceInventory
|
|||||||
IsRetracted = false,
|
IsRetracted = false,
|
||||||
IsPrivate = isPrivate,
|
IsPrivate = isPrivate,
|
||||||
Source = "vendor",
|
Source = "vendor",
|
||||||
ModuleCategory = moduleCategory
|
ModuleCategory = moduleCategory,
|
||||||
|
License = license,
|
||||||
|
LicenseConfidence = licenseConfidence,
|
||||||
|
IsPseudoVersion = isPseudoVersion
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform conflict analysis
|
||||||
|
var conflictAnalysis = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
goMod.Replaces.ToList(),
|
||||||
|
goMod.Excludes.ToList(),
|
||||||
|
retractedVersions);
|
||||||
|
|
||||||
|
// Analyze CGO usage in the module
|
||||||
|
var cgoAnalysis = GoCgoDetector.AnalyzeModule(project.RootPath);
|
||||||
|
|
||||||
|
// Detect main module license
|
||||||
|
var mainLicense = GoLicenseDetector.DetectLicense(project.RootPath);
|
||||||
|
|
||||||
return new SourceInventoryResult(
|
return new SourceInventoryResult(
|
||||||
goMod.ModulePath,
|
goMod.ModulePath,
|
||||||
goMod.GoVersion,
|
goMod.GoVersion,
|
||||||
modules.ToImmutableArray(),
|
modules.ToImmutableArray(),
|
||||||
retractedVersions);
|
retractedVersions,
|
||||||
|
conflictAnalysis,
|
||||||
|
cgoAnalysis,
|
||||||
|
mainLicense.SpdxIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects version conflicts in Go module dependencies.
|
||||||
|
/// Similar to Java's VersionConflictDetector for Maven artifacts.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class GoVersionConflictDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Conflict severity levels.
|
||||||
|
/// </summary>
|
||||||
|
public enum GoConflictSeverity
|
||||||
|
{
|
||||||
|
/// <summary>No conflict detected.</summary>
|
||||||
|
None = 0,
|
||||||
|
/// <summary>Minor version mismatch or informational.</summary>
|
||||||
|
Low = 1,
|
||||||
|
/// <summary>Potential compatibility issue.</summary>
|
||||||
|
Medium = 2,
|
||||||
|
/// <summary>Likely breaking change or security concern.</summary>
|
||||||
|
High = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Types of version conflicts in Go modules.
|
||||||
|
/// </summary>
|
||||||
|
public enum GoConflictType
|
||||||
|
{
|
||||||
|
/// <summary>No conflict.</summary>
|
||||||
|
None,
|
||||||
|
/// <summary>Module replaced with different version.</summary>
|
||||||
|
ReplaceOverride,
|
||||||
|
/// <summary>Module replaced with local path.</summary>
|
||||||
|
LocalReplacement,
|
||||||
|
/// <summary>Using pseudo-version (unreleased code).</summary>
|
||||||
|
PseudoVersion,
|
||||||
|
/// <summary>Major version mismatch in module path.</summary>
|
||||||
|
MajorVersionMismatch,
|
||||||
|
/// <summary>Multiple workspace modules require different versions.</summary>
|
||||||
|
WorkspaceConflict,
|
||||||
|
/// <summary>Excluded version is still being required.</summary>
|
||||||
|
ExcludedVersion,
|
||||||
|
/// <summary>Using a retracted version.</summary>
|
||||||
|
RetractedVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a detected version conflict.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GoVersionConflict(
|
||||||
|
string ModulePath,
|
||||||
|
string SelectedVersion,
|
||||||
|
ImmutableArray<string> RequestedVersions,
|
||||||
|
GoConflictSeverity Severity,
|
||||||
|
GoConflictType ConflictType,
|
||||||
|
string? Description)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets other versions that were requested but not selected.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> OtherVersions
|
||||||
|
=> RequestedVersions.Where(v => !v.Equals(SelectedVersion, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of conflict analysis for a module inventory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GoConflictAnalysis
|
||||||
|
{
|
||||||
|
public static readonly GoConflictAnalysis Empty = new(
|
||||||
|
ImmutableArray<GoVersionConflict>.Empty,
|
||||||
|
ImmutableDictionary<string, GoVersionConflict>.Empty);
|
||||||
|
|
||||||
|
public GoConflictAnalysis(
|
||||||
|
ImmutableArray<GoVersionConflict> conflicts,
|
||||||
|
ImmutableDictionary<string, GoVersionConflict> byModule)
|
||||||
|
{
|
||||||
|
Conflicts = conflicts;
|
||||||
|
_byModule = byModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ImmutableDictionary<string, GoVersionConflict> _byModule;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All detected conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<GoVersionConflict> Conflicts { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if any conflicts were detected.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasConflicts => Conflicts.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the highest severity among all conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public GoConflictSeverity MaxSeverity
|
||||||
|
=> Conflicts.Length > 0 ? Conflicts.Max(c => c.Severity) : GoConflictSeverity.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets conflict for a specific module if one exists.
|
||||||
|
/// </summary>
|
||||||
|
public GoVersionConflict? GetConflict(string modulePath)
|
||||||
|
=> _byModule.TryGetValue(modulePath, out var conflict) ? conflict : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes module inventory for version conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public static GoConflictAnalysis Analyze(
|
||||||
|
IReadOnlyList<GoSourceInventory.GoSourceModule> modules,
|
||||||
|
IReadOnlyList<GoModParser.GoModReplace> replaces,
|
||||||
|
IReadOnlyList<GoModParser.GoModExclude> excludes,
|
||||||
|
ImmutableArray<string> retractedVersions)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(modules);
|
||||||
|
ArgumentNullException.ThrowIfNull(replaces);
|
||||||
|
ArgumentNullException.ThrowIfNull(excludes);
|
||||||
|
|
||||||
|
if (modules.Count == 0)
|
||||||
|
{
|
||||||
|
return GoConflictAnalysis.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = new List<GoVersionConflict>();
|
||||||
|
|
||||||
|
// Build exclude set for quick lookup
|
||||||
|
var excludeSet = excludes
|
||||||
|
.Select(e => $"{e.Path}@{e.Version}")
|
||||||
|
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Build replace map
|
||||||
|
var replaceMap = replaces.ToDictionary(
|
||||||
|
r => r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath,
|
||||||
|
r => r,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var module in modules)
|
||||||
|
{
|
||||||
|
// Check for pseudo-version
|
||||||
|
if (IsPseudoVersion(module.Version))
|
||||||
|
{
|
||||||
|
conflicts.Add(new GoVersionConflict(
|
||||||
|
module.Path,
|
||||||
|
module.Version,
|
||||||
|
[module.Version],
|
||||||
|
GoConflictSeverity.Medium,
|
||||||
|
GoConflictType.PseudoVersion,
|
||||||
|
"Using pseudo-version indicates unreleased or unstable code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for replace directive conflicts
|
||||||
|
if (module.IsReplaced)
|
||||||
|
{
|
||||||
|
var severity = GoConflictSeverity.Low;
|
||||||
|
var conflictType = GoConflictType.ReplaceOverride;
|
||||||
|
var description = $"Module replaced with {module.ReplacementPath}";
|
||||||
|
|
||||||
|
// Local path replacement is higher risk
|
||||||
|
if (IsLocalPath(module.ReplacementPath))
|
||||||
|
{
|
||||||
|
severity = GoConflictSeverity.High;
|
||||||
|
conflictType = GoConflictType.LocalReplacement;
|
||||||
|
description = "Module replaced with local path - may not be reproducible";
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts.Add(new GoVersionConflict(
|
||||||
|
module.Path,
|
||||||
|
module.Version,
|
||||||
|
[module.Version],
|
||||||
|
severity,
|
||||||
|
conflictType,
|
||||||
|
description));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for excluded version being required
|
||||||
|
var versionedKey = $"{module.Path}@{module.Version}";
|
||||||
|
if (excludeSet.Contains(versionedKey))
|
||||||
|
{
|
||||||
|
conflicts.Add(new GoVersionConflict(
|
||||||
|
module.Path,
|
||||||
|
module.Version,
|
||||||
|
[module.Version],
|
||||||
|
GoConflictSeverity.High,
|
||||||
|
GoConflictType.ExcludedVersion,
|
||||||
|
"Required version is explicitly excluded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for retracted versions (in own module's go.mod)
|
||||||
|
if (module.IsRetracted || retractedVersions.Contains(module.Version))
|
||||||
|
{
|
||||||
|
conflicts.Add(new GoVersionConflict(
|
||||||
|
module.Path,
|
||||||
|
module.Version,
|
||||||
|
[module.Version],
|
||||||
|
GoConflictSeverity.High,
|
||||||
|
GoConflictType.RetractedVersion,
|
||||||
|
"Using a retracted version - may have known issues"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for major version mismatches
|
||||||
|
var modulesByBasePath = modules
|
||||||
|
.GroupBy(m => ExtractBasePath(m.Path), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Where(g => g.Count() > 1);
|
||||||
|
|
||||||
|
foreach (var group in modulesByBasePath)
|
||||||
|
{
|
||||||
|
var versions = group.Select(m => ExtractMajorVersion(m.Path)).Distinct().ToList();
|
||||||
|
if (versions.Count > 1)
|
||||||
|
{
|
||||||
|
foreach (var module in group)
|
||||||
|
{
|
||||||
|
var otherVersions = group
|
||||||
|
.Where(m => !m.Path.Equals(module.Path, StringComparison.Ordinal))
|
||||||
|
.Select(m => m.Version)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
conflicts.Add(new GoVersionConflict(
|
||||||
|
module.Path,
|
||||||
|
module.Version,
|
||||||
|
[module.Version, .. otherVersions],
|
||||||
|
GoConflictSeverity.Medium,
|
||||||
|
GoConflictType.MajorVersionMismatch,
|
||||||
|
$"Multiple major versions of same module: {string.Join(", ", versions)}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var byModule = conflicts
|
||||||
|
.GroupBy(c => c.ModulePath, StringComparer.Ordinal)
|
||||||
|
.Select(g => g.OrderByDescending(c => c.Severity).First())
|
||||||
|
.ToImmutableDictionary(c => c.ModulePath, c => c, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
return new GoConflictAnalysis(
|
||||||
|
[.. conflicts.OrderBy(c => c.ModulePath, StringComparer.Ordinal)],
|
||||||
|
byModule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes workspace for cross-module version conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public static GoConflictAnalysis AnalyzeWorkspace(
|
||||||
|
IReadOnlyList<GoSourceInventory.SourceInventoryResult> inventories)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(inventories);
|
||||||
|
|
||||||
|
if (inventories.Count < 2)
|
||||||
|
{
|
||||||
|
return GoConflictAnalysis.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = new List<GoVersionConflict>();
|
||||||
|
|
||||||
|
// Group all modules by path across workspace members
|
||||||
|
var allModules = inventories
|
||||||
|
.SelectMany(inv => inv.Modules)
|
||||||
|
.GroupBy(m => m.Path, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var group in allModules)
|
||||||
|
{
|
||||||
|
var versions = group
|
||||||
|
.Select(m => m.Version)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (versions.Count > 1)
|
||||||
|
{
|
||||||
|
// Different versions of same dependency across workspace
|
||||||
|
var selectedVersion = SelectMvsVersion(versions);
|
||||||
|
|
||||||
|
conflicts.Add(new GoVersionConflict(
|
||||||
|
group.Key,
|
||||||
|
selectedVersion,
|
||||||
|
[.. versions],
|
||||||
|
GoConflictSeverity.Low,
|
||||||
|
GoConflictType.WorkspaceConflict,
|
||||||
|
$"Workspace modules require different versions: {string.Join(", ", versions)}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var byModule = conflicts
|
||||||
|
.ToImmutableDictionary(c => c.ModulePath, c => c, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
return new GoConflictAnalysis([.. conflicts], byModule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if a version string is a pseudo-version.
|
||||||
|
/// Pseudo-versions have format: v0.0.0-yyyymmddhhmmss-abcdefabcdef
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPseudoVersion(string version)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PseudoVersionPattern().IsMatch(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if a path is a local filesystem path.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsLocalPath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts with ./ or ../ or /
|
||||||
|
if (path.StartsWith('.') || path.StartsWith('/') || path.StartsWith('\\'))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows absolute path
|
||||||
|
if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':')
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the base path without major version suffix.
|
||||||
|
/// Example: "github.com/user/repo/v2" -> "github.com/user/repo"
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractBasePath(string modulePath)
|
||||||
|
{
|
||||||
|
var match = MajorVersionSuffixPattern().Match(modulePath);
|
||||||
|
return match.Success ? modulePath[..^match.Length] : modulePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts major version from module path.
|
||||||
|
/// Example: "github.com/user/repo/v2" -> "v2"
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractMajorVersion(string modulePath)
|
||||||
|
{
|
||||||
|
var match = MajorVersionSuffixPattern().Match(modulePath);
|
||||||
|
return match.Success ? match.Value : "v0/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simulates Go's Minimal Version Selection to pick the highest version.
|
||||||
|
/// </summary>
|
||||||
|
private static string SelectMvsVersion(IEnumerable<string> versions)
|
||||||
|
{
|
||||||
|
// MVS picks the highest version among all requested
|
||||||
|
return versions
|
||||||
|
.OrderByDescending(v => v, SemVerComparer.Instance)
|
||||||
|
.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches pseudo-versions: v0.0.0-timestamp-hash or vX.Y.Z-pre.0.timestamp-hash
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"^v\d+\.\d+\.\d+(-[a-z0-9]+)?\.?\d*\.?\d{14}-[a-f0-9]{12}$", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex PseudoVersionPattern();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches major version suffix: /v2, /v3, etc.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"/v\d+$")]
|
||||||
|
private static partial Regex MajorVersionSuffixPattern();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comparer for semantic versions that handles Go module versions.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SemVerComparer : IComparer<string>
|
||||||
|
{
|
||||||
|
public static readonly SemVerComparer Instance = new();
|
||||||
|
|
||||||
|
public int Compare(string? x, string? y)
|
||||||
|
{
|
||||||
|
if (x is null && y is null) return 0;
|
||||||
|
if (x is null) return -1;
|
||||||
|
if (y is null) return 1;
|
||||||
|
|
||||||
|
var partsX = ParseVersion(x);
|
||||||
|
var partsY = ParseVersion(y);
|
||||||
|
|
||||||
|
// Compare major.minor.patch
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var comparison = partsX[i].CompareTo(partsY[i]);
|
||||||
|
if (comparison != 0) return comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare pre-release (no pre-release > pre-release)
|
||||||
|
var preX = partsX[3] > 0 || !string.IsNullOrEmpty(GetPrerelease(x));
|
||||||
|
var preY = partsY[3] > 0 || !string.IsNullOrEmpty(GetPrerelease(y));
|
||||||
|
|
||||||
|
if (!preX && preY) return 1;
|
||||||
|
if (preX && !preY) return -1;
|
||||||
|
|
||||||
|
return string.CompareOrdinal(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] ParseVersion(string version)
|
||||||
|
{
|
||||||
|
var result = new int[4]; // major, minor, patch, prerelease indicator
|
||||||
|
|
||||||
|
// Strip 'v' prefix
|
||||||
|
if (version.StartsWith('v') || version.StartsWith('V'))
|
||||||
|
{
|
||||||
|
version = version[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pseudo-versions specially
|
||||||
|
if (version.Contains('-'))
|
||||||
|
{
|
||||||
|
var dashIndex = version.IndexOf('-');
|
||||||
|
version = version[..dashIndex];
|
||||||
|
result[3] = 1; // Mark as pre-release
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = version.Split('.');
|
||||||
|
for (var i = 0; i < Math.Min(parts.Length, 3); i++)
|
||||||
|
{
|
||||||
|
if (int.TryParse(parts[i], out var num))
|
||||||
|
{
|
||||||
|
result[i] = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetPrerelease(string version)
|
||||||
|
{
|
||||||
|
var dashIndex = version.IndexOf('-');
|
||||||
|
return dashIndex >= 0 ? version[(dashIndex + 1)..] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
<EnableDefaultItems>false</EnableDefaultItems>
|
<EnableDefaultItems>false</EnableDefaultItems>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Go.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
|
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
|
||||||
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
|
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks dependency declarations from package.json files, mapping package names to their
|
||||||
|
/// declared scopes and version ranges. Used to classify dependencies during collection.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NodeDependencyIndex
|
||||||
|
{
|
||||||
|
private static readonly NodeDependencyIndex Empty = new(
|
||||||
|
new Dictionary<string, NodeDependencyDeclaration>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private readonly Dictionary<string, NodeDependencyDeclaration> _declarations;
|
||||||
|
|
||||||
|
private NodeDependencyIndex(Dictionary<string, NodeDependencyDeclaration> declarations)
|
||||||
|
{
|
||||||
|
_declarations = declarations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all declared dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<NodeDependencyDeclaration> Declarations => _declarations.Values;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a dependency index from the root package.json file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rootPath">The project root directory.</param>
|
||||||
|
/// <returns>A dependency index with all declared dependencies and their scopes.</returns>
|
||||||
|
public static NodeDependencyIndex Create(string rootPath)
|
||||||
|
{
|
||||||
|
var packageJsonPath = Path.Combine(rootPath, "package.json");
|
||||||
|
if (!File.Exists(packageJsonPath))
|
||||||
|
{
|
||||||
|
return Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(packageJsonPath);
|
||||||
|
using var document = JsonDocument.Parse(stream);
|
||||||
|
return CreateFromJson(document.RootElement);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return Empty;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a dependency index from a parsed package.json JSON element.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="root">The root JSON element of package.json.</param>
|
||||||
|
/// <returns>A dependency index with all declared dependencies and their scopes.</returns>
|
||||||
|
public static NodeDependencyIndex CreateFromJson(JsonElement root)
|
||||||
|
{
|
||||||
|
var declarations = new Dictionary<string, NodeDependencyDeclaration>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
ParseDependencySection(root, "dependencies", NodeDependencyScope.Production, declarations);
|
||||||
|
ParseDependencySection(root, "devDependencies", NodeDependencyScope.Development, declarations);
|
||||||
|
ParseDependencySection(root, "peerDependencies", NodeDependencyScope.Peer, declarations);
|
||||||
|
ParseDependencySection(root, "optionalDependencies", NodeDependencyScope.Optional, declarations);
|
||||||
|
|
||||||
|
if (declarations.Count == 0)
|
||||||
|
{
|
||||||
|
return Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NodeDependencyIndex(declarations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get the scope for a dependency by name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="packageName">The package name to look up.</param>
|
||||||
|
/// <param name="scope">The scope if found.</param>
|
||||||
|
/// <returns>True if the dependency was found in the index.</returns>
|
||||||
|
public bool TryGetScope(string packageName, out NodeDependencyScope scope)
|
||||||
|
{
|
||||||
|
if (_declarations.TryGetValue(packageName, out var declaration))
|
||||||
|
{
|
||||||
|
scope = declaration.Scope;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
scope = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get the full declaration for a dependency by name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="packageName">The package name to look up.</param>
|
||||||
|
/// <param name="declaration">The declaration if found.</param>
|
||||||
|
/// <returns>True if the dependency was found in the index.</returns>
|
||||||
|
public bool TryGetDeclaration(string packageName, out NodeDependencyDeclaration? declaration)
|
||||||
|
{
|
||||||
|
if (_declarations.TryGetValue(packageName, out var found))
|
||||||
|
{
|
||||||
|
declaration = found;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
declaration = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the dependency is optional (declared in optionalDependencies).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsOptional(string packageName)
|
||||||
|
{
|
||||||
|
return _declarations.TryGetValue(packageName, out var declaration)
|
||||||
|
&& declaration.Scope == NodeDependencyScope.Optional;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseDependencySection(
|
||||||
|
JsonElement root,
|
||||||
|
string sectionName,
|
||||||
|
NodeDependencyScope scope,
|
||||||
|
Dictionary<string, NodeDependencyDeclaration> declarations)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty(sectionName, out var section) ||
|
||||||
|
section.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var property in section.EnumerateObject())
|
||||||
|
{
|
||||||
|
var packageName = property.Name;
|
||||||
|
if (string.IsNullOrWhiteSpace(packageName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use the first declaration (higher priority sections should be parsed first)
|
||||||
|
// Production > Development > Peer > Optional
|
||||||
|
if (declarations.ContainsKey(packageName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? versionRange = null;
|
||||||
|
if (property.Value.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
versionRange = property.Value.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
declarations[packageName] = new NodeDependencyDeclaration(
|
||||||
|
packageName,
|
||||||
|
versionRange,
|
||||||
|
scope,
|
||||||
|
sectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a dependency declaration from package.json.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">The package name.</param>
|
||||||
|
/// <param name="VersionRange">The declared version range (e.g., "^1.2.3", "~1.0.0", ">=1.0.0").</param>
|
||||||
|
/// <param name="Scope">The scope derived from which section the dependency was declared in.</param>
|
||||||
|
/// <param name="Section">The original section name (e.g., "dependencies", "devDependencies").</param>
|
||||||
|
internal sealed record NodeDependencyDeclaration(
|
||||||
|
string Name,
|
||||||
|
string? VersionRange,
|
||||||
|
NodeDependencyScope Scope,
|
||||||
|
string Section);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the dependency scope in a Node.js package.json file.
|
||||||
|
/// Maps to the section where the dependency is declared.
|
||||||
|
/// </summary>
|
||||||
|
internal enum NodeDependencyScope
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Production dependency declared in the "dependencies" section.
|
||||||
|
/// Required at runtime.
|
||||||
|
/// </summary>
|
||||||
|
Production,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Development dependency declared in the "devDependencies" section.
|
||||||
|
/// Only needed during development/build.
|
||||||
|
/// </summary>
|
||||||
|
Development,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Peer dependency declared in the "peerDependencies" section.
|
||||||
|
/// Expected to be provided by the consuming package.
|
||||||
|
/// </summary>
|
||||||
|
Peer,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional dependency declared in the "optionalDependencies" section.
|
||||||
|
/// Installation failure does not cause npm install to fail.
|
||||||
|
/// </summary>
|
||||||
|
Optional
|
||||||
|
}
|
||||||
@@ -11,33 +11,48 @@ internal sealed class NodeLockData
|
|||||||
private static readonly NodeLockData Empty = new(
|
private static readonly NodeLockData Empty = new(
|
||||||
new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal),
|
new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal),
|
||||||
new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase),
|
new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase),
|
||||||
Array.Empty<NodeLockEntry>());
|
Array.Empty<NodeLockEntry>(),
|
||||||
|
NodeDependencyIndex.Create(string.Empty));
|
||||||
|
|
||||||
private readonly Dictionary<string, NodeLockEntry> _byPath;
|
private readonly Dictionary<string, NodeLockEntry> _byPath;
|
||||||
private readonly Dictionary<string, NodeLockEntry> _byName;
|
private readonly Dictionary<string, NodeLockEntry> _byName;
|
||||||
private readonly IReadOnlyCollection<NodeLockEntry> _declared;
|
private readonly IReadOnlyCollection<NodeLockEntry> _declared;
|
||||||
|
private readonly NodeDependencyIndex _dependencyIndex;
|
||||||
|
|
||||||
private NodeLockData(
|
private NodeLockData(
|
||||||
Dictionary<string, NodeLockEntry> byPath,
|
Dictionary<string, NodeLockEntry> byPath,
|
||||||
Dictionary<string, NodeLockEntry> byName,
|
Dictionary<string, NodeLockEntry> byName,
|
||||||
IReadOnlyCollection<NodeLockEntry> declared)
|
IReadOnlyCollection<NodeLockEntry> declared,
|
||||||
|
NodeDependencyIndex dependencyIndex)
|
||||||
{
|
{
|
||||||
_byPath = byPath;
|
_byPath = byPath;
|
||||||
_byName = byName;
|
_byName = byName;
|
||||||
_declared = declared;
|
_declared = declared;
|
||||||
|
_dependencyIndex = dependencyIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyCollection<NodeLockEntry> DeclaredPackages => _declared;
|
public IReadOnlyCollection<NodeLockEntry> DeclaredPackages => _declared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the dependency index built from package.json.
|
||||||
|
/// </summary>
|
||||||
|
public NodeDependencyIndex DependencyIndex => _dependencyIndex;
|
||||||
|
|
||||||
public static ValueTask<NodeLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
|
public static ValueTask<NodeLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var byPath = new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal);
|
var byPath = new Dictionary<string, NodeLockEntry>(StringComparer.Ordinal);
|
||||||
var byName = new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase);
|
var byName = new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
var declared = new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase);
|
var declared = new Dictionary<string, NodeLockEntry>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
LoadPackageLockJson(rootPath, byPath, byName, declared, cancellationToken);
|
// Build dependency index from package.json first
|
||||||
LoadYarnLock(rootPath, byName, declared);
|
var dependencyIndex = NodeDependencyIndex.Create(rootPath);
|
||||||
LoadPnpmLock(rootPath, byName, declared);
|
|
||||||
|
LoadPackageLockJson(rootPath, byPath, byName, declared, dependencyIndex, cancellationToken);
|
||||||
|
LoadYarnLock(rootPath, byName, declared, dependencyIndex);
|
||||||
|
LoadPnpmLock(rootPath, byName, declared, dependencyIndex);
|
||||||
|
|
||||||
|
// Add declared-only entries for packages in package.json but not in any lockfile
|
||||||
|
AddDeclaredOnlyFromPackageJson(declared, dependencyIndex);
|
||||||
|
|
||||||
if (byPath.Count == 0 && byName.Count == 0 && declared.Count == 0)
|
if (byPath.Count == 0 && byName.Count == 0 && declared.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -51,7 +66,47 @@ internal sealed class NodeLockData
|
|||||||
.ThenBy(static entry => entry.Locator ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(static entry => entry.Locator ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return ValueTask.FromResult(new NodeLockData(byPath, byName, declaredList));
|
return ValueTask.FromResult(new NodeLockData(byPath, byName, declaredList, dependencyIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds declared-only entries for packages in package.json that are not in any lockfile.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddDeclaredOnlyFromPackageJson(
|
||||||
|
IDictionary<string, NodeLockEntry> declared,
|
||||||
|
NodeDependencyIndex dependencyIndex)
|
||||||
|
{
|
||||||
|
foreach (var declaration in dependencyIndex.Declarations)
|
||||||
|
{
|
||||||
|
var key = $"{declaration.Name}@{declaration.VersionRange ?? "*"}".ToLowerInvariant();
|
||||||
|
|
||||||
|
// Only add if not already present from lockfiles
|
||||||
|
if (declared.ContainsKey(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have any version of this package
|
||||||
|
var hasAnyVersion = declared.Keys.Any(k =>
|
||||||
|
k.StartsWith($"{declaration.Name}@", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (hasAnyVersion)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new NodeLockEntry(
|
||||||
|
Source: "package.json",
|
||||||
|
Locator: $"package.json#{declaration.Section}",
|
||||||
|
Name: declaration.Name,
|
||||||
|
Version: declaration.VersionRange,
|
||||||
|
Resolved: null,
|
||||||
|
Integrity: null,
|
||||||
|
Scope: declaration.Scope,
|
||||||
|
IsOptional: declaration.Scope == NodeDependencyScope.Optional);
|
||||||
|
|
||||||
|
declared[key] = entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGet(string relativePath, string packageName, out NodeLockEntry? entry)
|
public bool TryGet(string relativePath, string packageName, out NodeLockEntry? entry)
|
||||||
@@ -81,7 +136,8 @@ internal sealed class NodeLockData
|
|||||||
string source,
|
string source,
|
||||||
string? locator,
|
string? locator,
|
||||||
string? inferredName,
|
string? inferredName,
|
||||||
JsonElement element)
|
JsonElement element,
|
||||||
|
NodeDependencyIndex? dependencyIndex = null)
|
||||||
{
|
{
|
||||||
string? name = inferredName;
|
string? name = inferredName;
|
||||||
string? version = null;
|
string? version = null;
|
||||||
@@ -123,7 +179,17 @@ internal sealed class NodeLockData
|
|||||||
}
|
}
|
||||||
|
|
||||||
var locatorValue = string.IsNullOrWhiteSpace(locator) ? null : locator;
|
var locatorValue = string.IsNullOrWhiteSpace(locator) ? null : locator;
|
||||||
return new NodeLockEntry(source, locatorValue, name!, version, resolved, integrity);
|
|
||||||
|
// Look up scope from dependency index
|
||||||
|
NodeDependencyScope? scope = null;
|
||||||
|
var isOptional = false;
|
||||||
|
if (dependencyIndex is not null && dependencyIndex.TryGetScope(name!, out var foundScope))
|
||||||
|
{
|
||||||
|
scope = foundScope;
|
||||||
|
isOptional = foundScope == NodeDependencyScope.Optional;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NodeLockEntry(source, locatorValue, name!, version, resolved, integrity, scope, isOptional);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void TraverseLegacyDependencies(
|
private static void TraverseLegacyDependencies(
|
||||||
@@ -131,14 +197,15 @@ internal sealed class NodeLockData
|
|||||||
JsonElement dependenciesElement,
|
JsonElement dependenciesElement,
|
||||||
IDictionary<string, NodeLockEntry> byPath,
|
IDictionary<string, NodeLockEntry> byPath,
|
||||||
IDictionary<string, NodeLockEntry> byName,
|
IDictionary<string, NodeLockEntry> byName,
|
||||||
IDictionary<string, NodeLockEntry> declared)
|
IDictionary<string, NodeLockEntry> declared,
|
||||||
|
NodeDependencyIndex dependencyIndex)
|
||||||
{
|
{
|
||||||
foreach (var dependency in dependenciesElement.EnumerateObject())
|
foreach (var dependency in dependenciesElement.EnumerateObject())
|
||||||
{
|
{
|
||||||
var depValue = dependency.Value;
|
var depValue = dependency.Value;
|
||||||
var path = $"{currentPath}/{dependency.Name}";
|
var path = $"{currentPath}/{dependency.Name}";
|
||||||
var normalizedPath = NormalizeLockPath(path);
|
var normalizedPath = NormalizeLockPath(path);
|
||||||
var entry = CreateEntry(PackageLockSource, normalizedPath, dependency.Name, depValue);
|
var entry = CreateEntry(PackageLockSource, normalizedPath, dependency.Name, depValue, dependencyIndex);
|
||||||
if (entry is not null)
|
if (entry is not null)
|
||||||
{
|
{
|
||||||
byPath[normalizedPath] = entry;
|
byPath[normalizedPath] = entry;
|
||||||
@@ -148,7 +215,7 @@ internal sealed class NodeLockData
|
|||||||
|
|
||||||
if (depValue.TryGetProperty("dependencies", out var childDependencies) && childDependencies.ValueKind == JsonValueKind.Object)
|
if (depValue.TryGetProperty("dependencies", out var childDependencies) && childDependencies.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
TraverseLegacyDependencies(path + "/node_modules", childDependencies, byPath, byName, declared);
|
TraverseLegacyDependencies(path + "/node_modules", childDependencies, byPath, byName, declared, dependencyIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,6 +225,7 @@ internal sealed class NodeLockData
|
|||||||
IDictionary<string, NodeLockEntry> byPath,
|
IDictionary<string, NodeLockEntry> byPath,
|
||||||
IDictionary<string, NodeLockEntry> byName,
|
IDictionary<string, NodeLockEntry> byName,
|
||||||
IDictionary<string, NodeLockEntry> declared,
|
IDictionary<string, NodeLockEntry> declared,
|
||||||
|
NodeDependencyIndex dependencyIndex,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var packageLockPath = Path.Combine(rootPath, "package-lock.json");
|
var packageLockPath = Path.Combine(rootPath, "package-lock.json");
|
||||||
@@ -181,7 +249,7 @@ internal sealed class NodeLockData
|
|||||||
var key = NormalizeLockPath(packageProperty.Name);
|
var key = NormalizeLockPath(packageProperty.Name);
|
||||||
var inferredName = ExtractNameFromPath(key);
|
var inferredName = ExtractNameFromPath(key);
|
||||||
|
|
||||||
var entry = CreateEntry(PackageLockSource, key, inferredName, packageProperty.Value);
|
var entry = CreateEntry(PackageLockSource, key, inferredName, packageProperty.Value, dependencyIndex);
|
||||||
if (entry is null)
|
if (entry is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -199,7 +267,7 @@ internal sealed class NodeLockData
|
|||||||
}
|
}
|
||||||
else if (root.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind == JsonValueKind.Object)
|
else if (root.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
TraverseLegacyDependencies("node_modules", dependenciesElement, byPath, byName, declared);
|
TraverseLegacyDependencies("node_modules", dependenciesElement, byPath, byName, declared, dependencyIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (IOException)
|
catch (IOException)
|
||||||
@@ -215,7 +283,8 @@ internal sealed class NodeLockData
|
|||||||
private static void LoadYarnLock(
|
private static void LoadYarnLock(
|
||||||
string rootPath,
|
string rootPath,
|
||||||
IDictionary<string, NodeLockEntry> byName,
|
IDictionary<string, NodeLockEntry> byName,
|
||||||
IDictionary<string, NodeLockEntry> declared)
|
IDictionary<string, NodeLockEntry> declared,
|
||||||
|
NodeDependencyIndex dependencyIndex)
|
||||||
{
|
{
|
||||||
var yarnLockPath = Path.Combine(rootPath, "yarn.lock");
|
var yarnLockPath = Path.Combine(rootPath, "yarn.lock");
|
||||||
if (!File.Exists(yarnLockPath))
|
if (!File.Exists(yarnLockPath))
|
||||||
@@ -250,7 +319,16 @@ internal sealed class NodeLockData
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entry = new NodeLockEntry(YarnLockSource, currentName, simpleName, version, resolved, integrity);
|
// Look up scope from dependency index
|
||||||
|
NodeDependencyScope? scope = null;
|
||||||
|
var isOptional = false;
|
||||||
|
if (dependencyIndex.TryGetScope(simpleName, out var foundScope))
|
||||||
|
{
|
||||||
|
scope = foundScope;
|
||||||
|
isOptional = foundScope == NodeDependencyScope.Optional;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new NodeLockEntry(YarnLockSource, currentName, simpleName, version, resolved, integrity, scope, isOptional);
|
||||||
byName[simpleName] = entry;
|
byName[simpleName] = entry;
|
||||||
AddDeclaration(declared, entry);
|
AddDeclaration(declared, entry);
|
||||||
version = null;
|
version = null;
|
||||||
@@ -300,7 +378,8 @@ internal sealed class NodeLockData
|
|||||||
private static void LoadPnpmLock(
|
private static void LoadPnpmLock(
|
||||||
string rootPath,
|
string rootPath,
|
||||||
IDictionary<string, NodeLockEntry> byName,
|
IDictionary<string, NodeLockEntry> byName,
|
||||||
IDictionary<string, NodeLockEntry> declared)
|
IDictionary<string, NodeLockEntry> declared,
|
||||||
|
NodeDependencyIndex dependencyIndex)
|
||||||
{
|
{
|
||||||
var pnpmLockPath = Path.Combine(rootPath, "pnpm-lock.yaml");
|
var pnpmLockPath = Path.Combine(rootPath, "pnpm-lock.yaml");
|
||||||
if (!File.Exists(pnpmLockPath))
|
if (!File.Exists(pnpmLockPath))
|
||||||
@@ -336,7 +415,16 @@ internal sealed class NodeLockData
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entry = new NodeLockEntry(PnpmLockSource, currentPackage, name, version, resolved, integrity);
|
// Look up scope from dependency index
|
||||||
|
NodeDependencyScope? scope = null;
|
||||||
|
var isOptional = false;
|
||||||
|
if (dependencyIndex.TryGetScope(name, out var foundScope))
|
||||||
|
{
|
||||||
|
scope = foundScope;
|
||||||
|
isOptional = foundScope == NodeDependencyScope.Optional;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new NodeLockEntry(PnpmLockSource, currentPackage, name, version, resolved, integrity, scope, isOptional);
|
||||||
byName[name] = entry;
|
byName[name] = entry;
|
||||||
AddDeclaration(declared, entry);
|
AddDeclaration(declared, entry);
|
||||||
version = null;
|
version = null;
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an entry from a Node.js lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Source">The lockfile source (e.g., "package-lock.json", "yarn.lock").</param>
|
||||||
|
/// <param name="Locator">The locator within the lockfile (path or key).</param>
|
||||||
|
/// <param name="Name">The package name.</param>
|
||||||
|
/// <param name="Version">The resolved version.</param>
|
||||||
|
/// <param name="Resolved">The URL where the package was resolved from.</param>
|
||||||
|
/// <param name="Integrity">The integrity hash (e.g., "sha512-...").</param>
|
||||||
|
/// <param name="Scope">The dependency scope from package.json (Production, Development, Peer, Optional).</param>
|
||||||
|
/// <param name="IsOptional">Whether this is an optional dependency.</param>
|
||||||
internal sealed record NodeLockEntry(
|
internal sealed record NodeLockEntry(
|
||||||
string Source,
|
string Source,
|
||||||
string? Locator,
|
string? Locator,
|
||||||
string Name,
|
string Name,
|
||||||
string? Version,
|
string? Version,
|
||||||
string? Resolved,
|
string? Resolved,
|
||||||
string? Integrity);
|
string? Integrity,
|
||||||
|
NodeDependencyScope? Scope = null,
|
||||||
|
bool IsOptional = false);
|
||||||
|
|
||||||
internal static class NodeLockEntryExtensions
|
internal static class NodeLockEntryExtensions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ internal sealed class NodePackage
|
|||||||
string? lockSource = null,
|
string? lockSource = null,
|
||||||
string? lockLocator = null,
|
string? lockLocator = null,
|
||||||
string? packageSha256 = null,
|
string? packageSha256 = null,
|
||||||
bool isYarnPnp = false)
|
bool isYarnPnp = false,
|
||||||
|
NodeDependencyScope? scope = null,
|
||||||
|
bool isOptional = false,
|
||||||
|
string? license = null)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
Version = version;
|
Version = version;
|
||||||
@@ -44,6 +47,9 @@ internal sealed class NodePackage
|
|||||||
LockLocator = lockLocator;
|
LockLocator = lockLocator;
|
||||||
PackageSha256 = packageSha256;
|
PackageSha256 = packageSha256;
|
||||||
IsYarnPnp = isYarnPnp;
|
IsYarnPnp = isYarnPnp;
|
||||||
|
Scope = scope;
|
||||||
|
IsOptional = isOptional;
|
||||||
|
License = license;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
@@ -84,6 +90,26 @@ internal sealed class NodePackage
|
|||||||
|
|
||||||
public bool IsYarnPnp { get; }
|
public bool IsYarnPnp { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dependency scope from package.json (Production, Development, Peer, Optional).
|
||||||
|
/// </summary>
|
||||||
|
public NodeDependencyScope? Scope { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The risk level derived from scope: "production", "development", "peer", or "optional".
|
||||||
|
/// </summary>
|
||||||
|
public string RiskLevel => NodeScopeClassifier.GetRiskLevel(Scope);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this is an optional dependency (declared in optionalDependencies).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsOptional { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The license declared in package.json (e.g., "MIT", "Apache-2.0").
|
||||||
|
/// </summary>
|
||||||
|
public string? License { get; }
|
||||||
|
|
||||||
private readonly List<NodeEntrypoint> _entrypoints = new();
|
private readonly List<NodeEntrypoint> _entrypoints = new();
|
||||||
private readonly List<NodeImportEdge> _imports = new();
|
private readonly List<NodeImportEdge> _imports = new();
|
||||||
private readonly List<NodeImportResolution> _resolvedImports = new();
|
private readonly List<NodeImportResolution> _resolvedImports = new();
|
||||||
@@ -317,6 +343,23 @@ internal sealed class NodePackage
|
|||||||
entries.Add(new KeyValuePair<string, string?>("yarnPnp", "true"));
|
entries.Add(new KeyValuePair<string, string?>("yarnPnp", "true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scope classification metadata
|
||||||
|
if (Scope is not null)
|
||||||
|
{
|
||||||
|
entries.Add(new KeyValuePair<string, string?>("scope", Scope.Value.ToString().ToLowerInvariant()));
|
||||||
|
entries.Add(new KeyValuePair<string, string?>("riskLevel", RiskLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsOptional)
|
||||||
|
{
|
||||||
|
entries.Add(new KeyValuePair<string, string?>("optional", "true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(License))
|
||||||
|
{
|
||||||
|
entries.Add(new KeyValuePair<string, string?>("license", License));
|
||||||
|
}
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|||||||
@@ -500,7 +500,12 @@ internal static class NodePackageCollector
|
|||||||
usedByEntrypoint: false,
|
usedByEntrypoint: false,
|
||||||
declaredOnly: true,
|
declaredOnly: true,
|
||||||
lockSource: entry.Source,
|
lockSource: entry.Source,
|
||||||
lockLocator: BuildLockLocator(entry));
|
lockLocator: BuildLockLocator(entry),
|
||||||
|
packageSha256: null,
|
||||||
|
isYarnPnp: false,
|
||||||
|
scope: entry.Scope,
|
||||||
|
isOptional: entry.IsOptional,
|
||||||
|
license: null);
|
||||||
|
|
||||||
packages.Add(declaredPackage);
|
packages.Add(declaredPackage);
|
||||||
}
|
}
|
||||||
@@ -614,6 +619,22 @@ internal static class NodePackageCollector
|
|||||||
var lockLocator = BuildLockLocator(lockEntry);
|
var lockLocator = BuildLockLocator(lockEntry);
|
||||||
var lockSource = lockEntry?.Source;
|
var lockSource = lockEntry?.Source;
|
||||||
|
|
||||||
|
// Get scope from lock entry (populated by NodeLockData from package.json)
|
||||||
|
// or from the dependency index directly if this is a root package
|
||||||
|
NodeDependencyScope? scope = lockEntry?.Scope;
|
||||||
|
var isOptional = lockEntry?.IsOptional ?? false;
|
||||||
|
if (scope is null && lockData?.DependencyIndex is { } dependencyIndex)
|
||||||
|
{
|
||||||
|
if (dependencyIndex.TryGetScope(name, out var foundScope))
|
||||||
|
{
|
||||||
|
scope = foundScope;
|
||||||
|
isOptional = foundScope == NodeDependencyScope.Optional;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract license from package.json
|
||||||
|
var license = ExtractLicense(root);
|
||||||
|
|
||||||
string? workspaceRoot = null;
|
string? workspaceRoot = null;
|
||||||
var isWorkspaceMember = workspaceIndex?.TryGetMember(relativeDirectory, out workspaceRoot) == true;
|
var isWorkspaceMember = workspaceIndex?.TryGetMember(relativeDirectory, out workspaceRoot) == true;
|
||||||
var workspaceRootValue = isWorkspaceMember && workspaceIndex is not null ? workspaceRoot : null;
|
var workspaceRootValue = isWorkspaceMember && workspaceIndex is not null ? workspaceRoot : null;
|
||||||
@@ -642,7 +663,10 @@ internal static class NodePackageCollector
|
|||||||
lockSource: lockSource,
|
lockSource: lockSource,
|
||||||
lockLocator: lockLocator,
|
lockLocator: lockLocator,
|
||||||
packageSha256: packageSha256,
|
packageSha256: packageSha256,
|
||||||
isYarnPnp: yarnPnpPresent);
|
isYarnPnp: yarnPnpPresent,
|
||||||
|
scope: scope,
|
||||||
|
isOptional: isOptional,
|
||||||
|
license: license);
|
||||||
|
|
||||||
AttachEntrypoints(context, package, root, relativeDirectory);
|
AttachEntrypoints(context, package, root, relativeDirectory);
|
||||||
|
|
||||||
@@ -813,6 +837,76 @@ internal static class NodePackageCollector
|
|||||||
|| name.Equals("install", StringComparison.OrdinalIgnoreCase)
|
|| name.Equals("install", StringComparison.OrdinalIgnoreCase)
|
||||||
|| name.Equals("postinstall", StringComparison.OrdinalIgnoreCase);
|
|| name.Equals("postinstall", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the license from package.json.
|
||||||
|
/// Handles both string format ("license": "MIT") and object format ("license": { "type": "MIT" }).
|
||||||
|
/// Also handles legacy "licenses" array format.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ExtractLicense(JsonElement root)
|
||||||
|
{
|
||||||
|
// Try modern "license" field (string)
|
||||||
|
if (root.TryGetProperty("license", out var licenseElement))
|
||||||
|
{
|
||||||
|
if (licenseElement.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var license = licenseElement.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(license))
|
||||||
|
{
|
||||||
|
return license.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (licenseElement.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
// Object format: { "type": "MIT", "url": "..." }
|
||||||
|
if (licenseElement.TryGetProperty("type", out var typeElement) &&
|
||||||
|
typeElement.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var license = typeElement.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(license))
|
||||||
|
{
|
||||||
|
return license.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try legacy "licenses" array format
|
||||||
|
if (root.TryGetProperty("licenses", out var licensesElement) &&
|
||||||
|
licensesElement.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var licenses = new List<string>();
|
||||||
|
foreach (var item in licensesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
string? license = null;
|
||||||
|
if (item.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
license = item.GetString();
|
||||||
|
}
|
||||||
|
else if (item.ValueKind == JsonValueKind.Object &&
|
||||||
|
item.TryGetProperty("type", out var itemTypeElement) &&
|
||||||
|
itemTypeElement.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
license = itemTypeElement.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(license))
|
||||||
|
{
|
||||||
|
licenses.Add(license.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (licenses.Count > 0)
|
||||||
|
{
|
||||||
|
// Combine multiple licenses with OR (SPDX expression style)
|
||||||
|
return licenses.Count == 1
|
||||||
|
? licenses[0]
|
||||||
|
: $"({string.Join(" OR ", licenses)})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AttachEntrypoints(LanguageAnalyzerContext context, NodePackage package, JsonElement root, string relativeDirectory)
|
private static void AttachEntrypoints(LanguageAnalyzerContext context, NodePackage package, JsonElement root, string relativeDirectory)
|
||||||
{
|
{
|
||||||
static string NormalizePath(string relativeDirectory, string? path)
|
static string NormalizePath(string relativeDirectory, string? path)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Node.js dependency scopes to risk levels for security analysis.
|
||||||
|
/// Modeled after <c>JavaScopeClassifier</c> for consistency across language analyzers.
|
||||||
|
/// </summary>
|
||||||
|
internal static class NodeScopeClassifier
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a Node.js dependency scope to a risk level string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">The dependency scope from package.json.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A risk level string: "production", "development", "peer", or "optional".
|
||||||
|
/// Defaults to "production" for null or unknown scopes.
|
||||||
|
/// </returns>
|
||||||
|
public static string GetRiskLevel(NodeDependencyScope? scope) => scope switch
|
||||||
|
{
|
||||||
|
null or NodeDependencyScope.Production => "production",
|
||||||
|
NodeDependencyScope.Development => "development",
|
||||||
|
NodeDependencyScope.Peer => "peer",
|
||||||
|
NodeDependencyScope.Optional => "optional",
|
||||||
|
_ => "production"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the scope indicates a direct (explicitly declared) dependency.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">The dependency scope from package.json.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// True for Production and Development scopes (direct dependencies).
|
||||||
|
/// False for Peer and Optional scopes (indirect or conditional dependencies).
|
||||||
|
/// </returns>
|
||||||
|
public static bool IsDirect(NodeDependencyScope? scope) => scope switch
|
||||||
|
{
|
||||||
|
NodeDependencyScope.Production or NodeDependencyScope.Development => true,
|
||||||
|
NodeDependencyScope.Peer or NodeDependencyScope.Optional => false,
|
||||||
|
null => true, // Unknown scope defaults to direct
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the dependency affects production runtime.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">The dependency scope from package.json.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// True for Production scope.
|
||||||
|
/// False for Development, Peer, and Optional scopes.
|
||||||
|
/// </returns>
|
||||||
|
public static bool IsProductionRuntime(NodeDependencyScope? scope) => scope switch
|
||||||
|
{
|
||||||
|
null or NodeDependencyScope.Production => true,
|
||||||
|
NodeDependencyScope.Development => false,
|
||||||
|
NodeDependencyScope.Peer => false, // Peer deps are provided by consumer
|
||||||
|
NodeDependencyScope.Optional => false, // May not be installed
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a package.json section name to a scope.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionName">The package.json section name (e.g., "dependencies", "devDependencies").</param>
|
||||||
|
/// <returns>The corresponding scope, or null if the section name is not recognized.</returns>
|
||||||
|
public static NodeDependencyScope? ParseSection(string? sectionName) => sectionName?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"dependencies" => NodeDependencyScope.Production,
|
||||||
|
"devdependencies" => NodeDependencyScope.Development,
|
||||||
|
"peerdependencies" => NodeDependencyScope.Peer,
|
||||||
|
"optionaldependencies" => NodeDependencyScope.Optional,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Conflicts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects version conflicts where the same Python package appears with multiple versions.
|
||||||
|
/// Common in containers with multiple virtualenvs or conflicting requirements.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class VersionConflictDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes discovered packages for version conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public static VersionConflictAnalysis Analyze(IEnumerable<PythonPackageInfo> packages)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(packages);
|
||||||
|
|
||||||
|
var packageList = packages.ToList();
|
||||||
|
if (packageList.Count == 0)
|
||||||
|
{
|
||||||
|
return VersionConflictAnalysis.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by normalized package name
|
||||||
|
var groups = packageList
|
||||||
|
.Where(p => !string.IsNullOrWhiteSpace(p.Version))
|
||||||
|
.GroupBy(p => p.NormalizedName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Where(g => g.Select(p => p.Version).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
return VersionConflictAnalysis.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = new List<PythonVersionConflict>();
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var versions = group
|
||||||
|
.Select(p => new PythonVersionOccurrence(
|
||||||
|
p.Version!,
|
||||||
|
p.Location,
|
||||||
|
p.MetadataPath ?? p.Location,
|
||||||
|
p.Kind.ToString(),
|
||||||
|
p.InstallerTool))
|
||||||
|
.OrderBy(v => v.Version, PythonVersionComparer.Instance)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
// Determine severity based on version distance
|
||||||
|
var severity = CalculateSeverity(versions);
|
||||||
|
|
||||||
|
conflicts.Add(new PythonVersionConflict(
|
||||||
|
group.Key,
|
||||||
|
group.First().Name, // Original non-normalized name
|
||||||
|
versions,
|
||||||
|
severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VersionConflictAnalysis(
|
||||||
|
[.. conflicts.OrderBy(c => c.NormalizedName, StringComparer.Ordinal)],
|
||||||
|
conflicts.Count,
|
||||||
|
conflicts.Max(c => c.Severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes packages from discovery result for version conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public static VersionConflictAnalysis Analyze(PythonPackageDiscoveryResult discoveryResult)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(discoveryResult);
|
||||||
|
return Analyze(discoveryResult.Packages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a specific package has version conflicts in the given package set.
|
||||||
|
/// </summary>
|
||||||
|
public static PythonVersionConflict? GetConflict(
|
||||||
|
IEnumerable<PythonPackageInfo> packages,
|
||||||
|
string packageName)
|
||||||
|
{
|
||||||
|
var normalizedName = PythonPackageInfo.NormalizeName(packageName);
|
||||||
|
var analysis = Analyze(packages);
|
||||||
|
return analysis.GetConflict(normalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConflictSeverity CalculateSeverity(ImmutableArray<PythonVersionOccurrence> versions)
|
||||||
|
{
|
||||||
|
var versionStrings = versions.Select(v => v.Version).Distinct().ToList();
|
||||||
|
|
||||||
|
if (versionStrings.Count == 1)
|
||||||
|
{
|
||||||
|
return ConflictSeverity.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as PEP 440 versions
|
||||||
|
var parsedVersions = versionStrings
|
||||||
|
.Select(TryParsePep440Version)
|
||||||
|
.Where(v => v is not null)
|
||||||
|
.Cast<Pep440Version>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (parsedVersions.Count < 2)
|
||||||
|
{
|
||||||
|
// Can't determine severity without parseable versions
|
||||||
|
return ConflictSeverity.Medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for epoch differences (critical - completely different version schemes)
|
||||||
|
var epochs = parsedVersions.Select(v => v.Epoch).Distinct().ToList();
|
||||||
|
if (epochs.Count > 1)
|
||||||
|
{
|
||||||
|
return ConflictSeverity.High;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for major version differences (high severity)
|
||||||
|
var majorVersions = parsedVersions.Select(v => v.Major).Distinct().ToList();
|
||||||
|
if (majorVersions.Count > 1)
|
||||||
|
{
|
||||||
|
return ConflictSeverity.High;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for minor version differences (medium severity)
|
||||||
|
var minorVersions = parsedVersions.Select(v => v.Minor).Distinct().ToList();
|
||||||
|
if (minorVersions.Count > 1)
|
||||||
|
{
|
||||||
|
return ConflictSeverity.Medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only patch/micro version differences (low severity)
|
||||||
|
return ConflictSeverity.Low;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a PEP 440 version string.
|
||||||
|
/// Handles: epoch, release segments, pre/post/dev releases, local versions.
|
||||||
|
/// </summary>
|
||||||
|
private static Pep440Version? TryParsePep440Version(string version)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PEP 440 pattern:
|
||||||
|
// [N!]N(.N)*[{a|b|rc}N][.postN][.devN][+local]
|
||||||
|
var match = Pep440VersionPattern().Match(version);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var epoch = 0;
|
||||||
|
if (match.Groups["epoch"].Success && int.TryParse(match.Groups["epoch"].Value, out var e))
|
||||||
|
{
|
||||||
|
epoch = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
var release = match.Groups["release"].Value;
|
||||||
|
var releaseParts = release.Split('.');
|
||||||
|
|
||||||
|
if (!int.TryParse(releaseParts[0], out var major))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minor = releaseParts.Length > 1 && int.TryParse(releaseParts[1], out var m) ? m : 0;
|
||||||
|
var micro = releaseParts.Length > 2 && int.TryParse(releaseParts[2], out var p) ? p : 0;
|
||||||
|
|
||||||
|
string? preRelease = null;
|
||||||
|
if (match.Groups["pre"].Success)
|
||||||
|
{
|
||||||
|
preRelease = match.Groups["pre"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? postRelease = null;
|
||||||
|
if (match.Groups["post"].Success)
|
||||||
|
{
|
||||||
|
postRelease = match.Groups["post"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? devRelease = null;
|
||||||
|
if (match.Groups["dev"].Success)
|
||||||
|
{
|
||||||
|
devRelease = match.Groups["dev"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? local = null;
|
||||||
|
if (match.Groups["local"].Success)
|
||||||
|
{
|
||||||
|
local = match.Groups["local"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Pep440Version(epoch, major, minor, micro, preRelease, postRelease, devRelease, local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PEP 440 version pattern
|
||||||
|
[GeneratedRegex(
|
||||||
|
@"^((?<epoch>\d+)!)?(?<release>\d+(\.\d+)*)((?<pre>(a|alpha|b|beta|c|rc)\d*))?(\.?(?<post>post\d*))?(\.?(?<dev>dev\d*))?(\+(?<local>[a-z0-9.]+))?$",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex Pep440VersionPattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of version conflict analysis.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record VersionConflictAnalysis(
|
||||||
|
ImmutableArray<PythonVersionConflict> Conflicts,
|
||||||
|
int TotalConflicts,
|
||||||
|
ConflictSeverity MaxSeverity)
|
||||||
|
{
|
||||||
|
public static readonly VersionConflictAnalysis Empty = new([], 0, ConflictSeverity.None);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if any conflicts were found.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasConflicts => TotalConflicts > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets conflicts for a specific package.
|
||||||
|
/// </summary>
|
||||||
|
public PythonVersionConflict? GetConflict(string normalizedName)
|
||||||
|
=> Conflicts.FirstOrDefault(c =>
|
||||||
|
string.Equals(c.NormalizedName, normalizedName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets high-severity conflicts only.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<PythonVersionConflict> HighSeverityConflicts =>
|
||||||
|
Conflicts.Where(c => c.Severity == ConflictSeverity.High).ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a version conflict for a single Python package.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record PythonVersionConflict(
|
||||||
|
string NormalizedName,
|
||||||
|
string OriginalName,
|
||||||
|
ImmutableArray<PythonVersionOccurrence> Versions,
|
||||||
|
ConflictSeverity Severity)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the PURL for this package (without version).
|
||||||
|
/// </summary>
|
||||||
|
public string Purl => $"pkg:pypi/{NormalizedName.Replace('_', '-')}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all unique version strings.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> UniqueVersions
|
||||||
|
=> Versions.Select(v => v.Version).Distinct();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the versions as a comma-separated string.
|
||||||
|
/// </summary>
|
||||||
|
public string VersionsString
|
||||||
|
=> string.Join(",", UniqueVersions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of locations where conflicting versions are found.
|
||||||
|
/// </summary>
|
||||||
|
public int LocationCount => Versions.Select(v => v.Location).Distinct().Count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single occurrence of a version.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record PythonVersionOccurrence(
|
||||||
|
string Version,
|
||||||
|
string Location,
|
||||||
|
string MetadataPath,
|
||||||
|
string PackageKind,
|
||||||
|
string? InstallerTool);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level of a version conflict.
|
||||||
|
/// </summary>
|
||||||
|
internal enum ConflictSeverity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No conflict.
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only micro/patch version differences (likely compatible).
|
||||||
|
/// </summary>
|
||||||
|
Low = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minor version differences (may have API changes).
|
||||||
|
/// </summary>
|
||||||
|
Medium = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Major version or epoch differences (likely incompatible).
|
||||||
|
/// </summary>
|
||||||
|
High = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parsed PEP 440 version.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record Pep440Version(
|
||||||
|
int Epoch,
|
||||||
|
int Major,
|
||||||
|
int Minor,
|
||||||
|
int Micro,
|
||||||
|
string? PreRelease,
|
||||||
|
string? PostRelease,
|
||||||
|
string? DevRelease,
|
||||||
|
string? LocalVersion)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether this is a pre-release version.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPreRelease => PreRelease is not null || DevRelease is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the release tuple as a comparable string.
|
||||||
|
/// </summary>
|
||||||
|
public string ReleaseTuple => $"{Epoch}!{Major}.{Minor}.{Micro}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comparer for PEP 440 version strings.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PythonVersionComparer : IComparer<string>
|
||||||
|
{
|
||||||
|
public static readonly PythonVersionComparer Instance = new();
|
||||||
|
|
||||||
|
public int Compare(string? x, string? y)
|
||||||
|
{
|
||||||
|
if (x is null && y is null) return 0;
|
||||||
|
if (x is null) return -1;
|
||||||
|
if (y is null) return 1;
|
||||||
|
|
||||||
|
// Normalize versions for comparison
|
||||||
|
var xNorm = NormalizeVersion(x);
|
||||||
|
var yNorm = NormalizeVersion(y);
|
||||||
|
|
||||||
|
var xParts = xNorm.Split(['.', '-', '_'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var yParts = yNorm.Split(['.', '-', '_'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
var maxParts = Math.Max(xParts.Length, yParts.Length);
|
||||||
|
|
||||||
|
for (int i = 0; i < maxParts; i++)
|
||||||
|
{
|
||||||
|
var xPart = i < xParts.Length ? xParts[i] : "0";
|
||||||
|
var yPart = i < yParts.Length ? yParts[i] : "0";
|
||||||
|
|
||||||
|
// Try numeric comparison first
|
||||||
|
if (int.TryParse(xPart, out var xNum) && int.TryParse(yPart, out var yNum))
|
||||||
|
{
|
||||||
|
var numCompare = xNum.CompareTo(yNum);
|
||||||
|
if (numCompare != 0) return numCompare;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fall back to string comparison
|
||||||
|
var strCompare = string.Compare(xPart, yPart, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (strCompare != 0) return strCompare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeVersion(string version)
|
||||||
|
{
|
||||||
|
// Remove epoch for simple comparison
|
||||||
|
var epochIdx = version.IndexOf('!');
|
||||||
|
if (epochIdx >= 0)
|
||||||
|
{
|
||||||
|
version = version[(epochIdx + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove local version
|
||||||
|
var localIdx = version.IndexOf('+');
|
||||||
|
if (localIdx >= 0)
|
||||||
|
{
|
||||||
|
version = version[..localIdx];
|
||||||
|
}
|
||||||
|
|
||||||
|
return version.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Licensing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes Python license classifiers and license strings to SPDX expressions.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class SpdxLicenseNormalizer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps PyPI classifiers to SPDX identifiers.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly FrozenDictionary<string, string> ClassifierToSpdx =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// OSI Approved licenses
|
||||||
|
["License :: OSI Approved :: MIT License"] = "MIT",
|
||||||
|
["License :: OSI Approved :: MIT No Attribution License (MIT-0)"] = "MIT-0",
|
||||||
|
["License :: OSI Approved :: Apache Software License"] = "Apache-2.0",
|
||||||
|
["License :: OSI Approved :: BSD License"] = "BSD-3-Clause",
|
||||||
|
["License :: OSI Approved :: GNU General Public License (GPL)"] = "GPL-3.0-only",
|
||||||
|
["License :: OSI Approved :: GNU General Public License v2 (GPLv2)"] = "GPL-2.0-only",
|
||||||
|
["License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)"] = "GPL-2.0-or-later",
|
||||||
|
["License :: OSI Approved :: GNU General Public License v3 (GPLv3)"] = "GPL-3.0-only",
|
||||||
|
["License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)"] = "GPL-3.0-or-later",
|
||||||
|
["License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)"] = "LGPL-2.0-only",
|
||||||
|
["License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)"] = "LGPL-2.0-or-later",
|
||||||
|
["License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)"] = "LGPL-3.0-only",
|
||||||
|
["License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)"] = "LGPL-3.0-or-later",
|
||||||
|
["License :: OSI Approved :: GNU Affero General Public License v3"] = "AGPL-3.0-only",
|
||||||
|
["License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)"] = "AGPL-3.0-or-later",
|
||||||
|
["License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)"] = "MPL-2.0",
|
||||||
|
["License :: OSI Approved :: Mozilla Public License 1.1 (MPL 1.1)"] = "MPL-1.1",
|
||||||
|
["License :: OSI Approved :: ISC License (ISCL)"] = "ISC",
|
||||||
|
["License :: OSI Approved :: Python Software Foundation License"] = "PSF-2.0",
|
||||||
|
["License :: OSI Approved :: Zope Public License"] = "ZPL-2.1",
|
||||||
|
["License :: OSI Approved :: Eclipse Public License 1.0 (EPL-1.0)"] = "EPL-1.0",
|
||||||
|
["License :: OSI Approved :: Eclipse Public License 2.0 (EPL-2.0)"] = "EPL-2.0",
|
||||||
|
["License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)"] = "EUPL-1.2",
|
||||||
|
["License :: OSI Approved :: Academic Free License (AFL)"] = "AFL-3.0",
|
||||||
|
["License :: OSI Approved :: Artistic License"] = "Artistic-2.0",
|
||||||
|
["License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0)"] = "BSL-1.0",
|
||||||
|
["License :: OSI Approved :: Common Development and Distribution License 1.0 (CDDL-1.0)"] = "CDDL-1.0",
|
||||||
|
["License :: OSI Approved :: Historical Permission Notice and Disclaimer (HPND)"] = "HPND",
|
||||||
|
["License :: OSI Approved :: IBM Public License"] = "IPL-1.0",
|
||||||
|
["License :: OSI Approved :: Intel Open Source License"] = "Intel",
|
||||||
|
["License :: OSI Approved :: Jabber Open Source License"] = "JOSL-1.0",
|
||||||
|
["License :: OSI Approved :: Open Software License 3.0 (OSL-3.0)"] = "OSL-3.0",
|
||||||
|
["License :: OSI Approved :: PostgreSQL License"] = "PostgreSQL",
|
||||||
|
["License :: OSI Approved :: The Unlicense (Unlicense)"] = "Unlicense",
|
||||||
|
["License :: OSI Approved :: Universal Permissive License (UPL)"] = "UPL-1.0",
|
||||||
|
["License :: OSI Approved :: W3C License"] = "W3C",
|
||||||
|
["License :: OSI Approved :: zlib/libpng License"] = "Zlib",
|
||||||
|
|
||||||
|
// BSD variants (common on PyPI)
|
||||||
|
["License :: OSI Approved :: BSD 2-Clause License"] = "BSD-2-Clause",
|
||||||
|
["License :: OSI Approved :: BSD 3-Clause License"] = "BSD-3-Clause",
|
||||||
|
["License :: OSI Approved :: BSD-2-Clause"] = "BSD-2-Clause",
|
||||||
|
["License :: OSI Approved :: BSD-3-Clause"] = "BSD-3-Clause",
|
||||||
|
|
||||||
|
// Public domain and CC0
|
||||||
|
["License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication"] = "CC0-1.0",
|
||||||
|
["License :: Public Domain"] = "Unlicense",
|
||||||
|
|
||||||
|
// Other common ones
|
||||||
|
["License :: Other/Proprietary License"] = "LicenseRef-Proprietary",
|
||||||
|
["License :: Freeware"] = "LicenseRef-Freeware",
|
||||||
|
["License :: Freely Distributable"] = "LicenseRef-FreelyDistributable",
|
||||||
|
|
||||||
|
// DFSG Free licenses
|
||||||
|
["License :: DFSG approved"] = "LicenseRef-DFSG-Approved",
|
||||||
|
}.ToFrozenDictionary();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps common license strings to SPDX identifiers.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly FrozenDictionary<string, string> LicenseStringToSpdx =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// MIT variations
|
||||||
|
["MIT"] = "MIT",
|
||||||
|
["MIT License"] = "MIT",
|
||||||
|
["MIT license"] = "MIT",
|
||||||
|
["The MIT License"] = "MIT",
|
||||||
|
["MIT-0"] = "MIT-0",
|
||||||
|
|
||||||
|
// Apache variations
|
||||||
|
["Apache"] = "Apache-2.0",
|
||||||
|
["Apache 2"] = "Apache-2.0",
|
||||||
|
["Apache 2.0"] = "Apache-2.0",
|
||||||
|
["Apache-2"] = "Apache-2.0",
|
||||||
|
["Apache-2.0"] = "Apache-2.0",
|
||||||
|
["Apache License"] = "Apache-2.0",
|
||||||
|
["Apache License 2.0"] = "Apache-2.0",
|
||||||
|
["Apache License, Version 2.0"] = "Apache-2.0",
|
||||||
|
["Apache Software License"] = "Apache-2.0",
|
||||||
|
["ASL 2.0"] = "Apache-2.0",
|
||||||
|
|
||||||
|
// BSD variations
|
||||||
|
["BSD"] = "BSD-3-Clause",
|
||||||
|
["BSD License"] = "BSD-3-Clause",
|
||||||
|
["BSD license"] = "BSD-3-Clause",
|
||||||
|
["BSD-2"] = "BSD-2-Clause",
|
||||||
|
["BSD 2-Clause"] = "BSD-2-Clause",
|
||||||
|
["BSD-2-Clause"] = "BSD-2-Clause",
|
||||||
|
["BSD-3"] = "BSD-3-Clause",
|
||||||
|
["BSD 3-Clause"] = "BSD-3-Clause",
|
||||||
|
["BSD-3-Clause"] = "BSD-3-Clause",
|
||||||
|
["Simplified BSD"] = "BSD-2-Clause",
|
||||||
|
["New BSD"] = "BSD-3-Clause",
|
||||||
|
["Modified BSD"] = "BSD-3-Clause",
|
||||||
|
|
||||||
|
// GPL variations
|
||||||
|
["GPL"] = "GPL-3.0-only",
|
||||||
|
["GPLv2"] = "GPL-2.0-only",
|
||||||
|
["GPL v2"] = "GPL-2.0-only",
|
||||||
|
["GPL-2"] = "GPL-2.0-only",
|
||||||
|
["GPL-2.0"] = "GPL-2.0-only",
|
||||||
|
["GPL-2.0-only"] = "GPL-2.0-only",
|
||||||
|
["GPL-2.0+"] = "GPL-2.0-or-later",
|
||||||
|
["GPL-2.0-or-later"] = "GPL-2.0-or-later",
|
||||||
|
["GPLv3"] = "GPL-3.0-only",
|
||||||
|
["GPL v3"] = "GPL-3.0-only",
|
||||||
|
["GPL-3"] = "GPL-3.0-only",
|
||||||
|
["GPL-3.0"] = "GPL-3.0-only",
|
||||||
|
["GPL-3.0-only"] = "GPL-3.0-only",
|
||||||
|
["GPL-3.0+"] = "GPL-3.0-or-later",
|
||||||
|
["GPL-3.0-or-later"] = "GPL-3.0-or-later",
|
||||||
|
["GNU General Public License"] = "GPL-3.0-only",
|
||||||
|
["GNU General Public License v3"] = "GPL-3.0-only",
|
||||||
|
|
||||||
|
// LGPL variations
|
||||||
|
["LGPL"] = "LGPL-3.0-only",
|
||||||
|
["LGPLv2"] = "LGPL-2.0-only",
|
||||||
|
["LGPL-2.0"] = "LGPL-2.0-only",
|
||||||
|
["LGPL-2.1"] = "LGPL-2.1-only",
|
||||||
|
["LGPLv3"] = "LGPL-3.0-only",
|
||||||
|
["LGPL-3.0"] = "LGPL-3.0-only",
|
||||||
|
["GNU Lesser General Public License"] = "LGPL-3.0-only",
|
||||||
|
|
||||||
|
// AGPL variations
|
||||||
|
["AGPL"] = "AGPL-3.0-only",
|
||||||
|
["AGPLv3"] = "AGPL-3.0-only",
|
||||||
|
["AGPL-3.0"] = "AGPL-3.0-only",
|
||||||
|
|
||||||
|
// MPL variations
|
||||||
|
["MPL"] = "MPL-2.0",
|
||||||
|
["MPL 2.0"] = "MPL-2.0",
|
||||||
|
["MPL-2.0"] = "MPL-2.0",
|
||||||
|
["Mozilla Public License 2.0"] = "MPL-2.0",
|
||||||
|
|
||||||
|
// ISC
|
||||||
|
["ISC"] = "ISC",
|
||||||
|
["ISC License"] = "ISC",
|
||||||
|
|
||||||
|
// Other common licenses
|
||||||
|
["PSF"] = "PSF-2.0",
|
||||||
|
["Python Software Foundation License"] = "PSF-2.0",
|
||||||
|
["PSFL"] = "PSF-2.0",
|
||||||
|
["Unlicense"] = "Unlicense",
|
||||||
|
["The Unlicense"] = "Unlicense",
|
||||||
|
["CC0"] = "CC0-1.0",
|
||||||
|
["CC0 1.0"] = "CC0-1.0",
|
||||||
|
["CC0-1.0"] = "CC0-1.0",
|
||||||
|
["Public Domain"] = "Unlicense",
|
||||||
|
["Zlib"] = "Zlib",
|
||||||
|
["zlib"] = "Zlib",
|
||||||
|
["Boost"] = "BSL-1.0",
|
||||||
|
["BSL-1.0"] = "BSL-1.0",
|
||||||
|
["EPL"] = "EPL-2.0",
|
||||||
|
["EPL-1.0"] = "EPL-1.0",
|
||||||
|
["EPL-2.0"] = "EPL-2.0",
|
||||||
|
["Eclipse"] = "EPL-2.0",
|
||||||
|
["Eclipse Public License"] = "EPL-2.0",
|
||||||
|
["Artistic"] = "Artistic-2.0",
|
||||||
|
["Artistic License"] = "Artistic-2.0",
|
||||||
|
["PostgreSQL"] = "PostgreSQL",
|
||||||
|
["W3C"] = "W3C",
|
||||||
|
["WTFPL"] = "WTFPL",
|
||||||
|
}.ToFrozenDictionary();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a Python package's license information to an SPDX expression.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="license">The license field from METADATA.</param>
|
||||||
|
/// <param name="classifiers">The classifiers from METADATA.</param>
|
||||||
|
/// <param name="licenseExpression">PEP 639 license-expression field (if present).</param>
|
||||||
|
/// <returns>The normalized SPDX expression or null if not determinable.</returns>
|
||||||
|
public static string? Normalize(
|
||||||
|
string? license,
|
||||||
|
IEnumerable<string>? classifiers,
|
||||||
|
string? licenseExpression = null)
|
||||||
|
{
|
||||||
|
// PEP 639 license expression takes precedence
|
||||||
|
if (!string.IsNullOrWhiteSpace(licenseExpression))
|
||||||
|
{
|
||||||
|
// Validate it looks like an SPDX expression
|
||||||
|
if (IsValidSpdxExpression(licenseExpression))
|
||||||
|
{
|
||||||
|
return licenseExpression.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try classifiers next (most reliable)
|
||||||
|
if (classifiers is not null)
|
||||||
|
{
|
||||||
|
var spdxFromClassifier = NormalizeFromClassifiers(classifiers);
|
||||||
|
if (spdxFromClassifier is not null)
|
||||||
|
{
|
||||||
|
return spdxFromClassifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the license string
|
||||||
|
if (!string.IsNullOrWhiteSpace(license))
|
||||||
|
{
|
||||||
|
var spdxFromString = NormalizeFromString(license);
|
||||||
|
if (spdxFromString is not null)
|
||||||
|
{
|
||||||
|
return spdxFromString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes license classifiers to SPDX.
|
||||||
|
/// </summary>
|
||||||
|
public static string? NormalizeFromClassifiers(IEnumerable<string> classifiers)
|
||||||
|
{
|
||||||
|
var spdxIds = new List<string>();
|
||||||
|
|
||||||
|
foreach (var classifier in classifiers)
|
||||||
|
{
|
||||||
|
if (ClassifierToSpdx.TryGetValue(classifier.Trim(), out var spdxId))
|
||||||
|
{
|
||||||
|
if (!spdxIds.Contains(spdxId, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
spdxIds.Add(spdxId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spdxIds.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spdxIds.Count == 1)
|
||||||
|
{
|
||||||
|
return spdxIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple licenses - create OR expression (dual licensing)
|
||||||
|
return string.Join(" OR ", spdxIds.OrderBy(s => s, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a license string to SPDX.
|
||||||
|
/// </summary>
|
||||||
|
public static string? NormalizeFromString(string license)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(license))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = license.Trim();
|
||||||
|
|
||||||
|
// Direct lookup
|
||||||
|
if (LicenseStringToSpdx.TryGetValue(trimmed, out var spdxId))
|
||||||
|
{
|
||||||
|
return spdxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try normalized lookup (remove common suffixes/prefixes)
|
||||||
|
var normalized = NormalizeLicenseString(trimmed);
|
||||||
|
if (LicenseStringToSpdx.TryGetValue(normalized, out spdxId))
|
||||||
|
{
|
||||||
|
return spdxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try pattern matching for known patterns
|
||||||
|
spdxId = TryPatternMatch(trimmed);
|
||||||
|
if (spdxId is not null)
|
||||||
|
{
|
||||||
|
return spdxId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't normalize - return as LicenseRef
|
||||||
|
if (IsPlausibleLicenseName(trimmed))
|
||||||
|
{
|
||||||
|
return $"LicenseRef-{SanitizeForSpdx(trimmed)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryPatternMatch(string license)
|
||||||
|
{
|
||||||
|
// MIT pattern
|
||||||
|
if (MitPattern().IsMatch(license))
|
||||||
|
{
|
||||||
|
return "MIT";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apache pattern
|
||||||
|
if (ApachePattern().IsMatch(license))
|
||||||
|
{
|
||||||
|
return "Apache-2.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// BSD pattern
|
||||||
|
var bsdMatch = BsdPattern().Match(license);
|
||||||
|
if (bsdMatch.Success)
|
||||||
|
{
|
||||||
|
var clauseCount = bsdMatch.Groups["clauses"].Value;
|
||||||
|
return clauseCount switch
|
||||||
|
{
|
||||||
|
"2" => "BSD-2-Clause",
|
||||||
|
"3" => "BSD-3-Clause",
|
||||||
|
"4" => "BSD-4-Clause",
|
||||||
|
_ => "BSD-3-Clause"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPL pattern
|
||||||
|
var gplMatch = GplPattern().Match(license);
|
||||||
|
if (gplMatch.Success)
|
||||||
|
{
|
||||||
|
var version = gplMatch.Groups["version"].Value;
|
||||||
|
var orLater = gplMatch.Groups["orlater"].Success;
|
||||||
|
|
||||||
|
return version switch
|
||||||
|
{
|
||||||
|
"2" or "2.0" => orLater ? "GPL-2.0-or-later" : "GPL-2.0-only",
|
||||||
|
"3" or "3.0" => orLater ? "GPL-3.0-or-later" : "GPL-3.0-only",
|
||||||
|
_ => "GPL-3.0-only"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// LGPL pattern
|
||||||
|
var lgplMatch = LgplPattern().Match(license);
|
||||||
|
if (lgplMatch.Success)
|
||||||
|
{
|
||||||
|
var version = lgplMatch.Groups["version"].Value;
|
||||||
|
return version switch
|
||||||
|
{
|
||||||
|
"2" or "2.0" => "LGPL-2.0-only",
|
||||||
|
"2.1" => "LGPL-2.1-only",
|
||||||
|
"3" or "3.0" => "LGPL-3.0-only",
|
||||||
|
_ => "LGPL-3.0-only"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLicenseString(string license)
|
||||||
|
{
|
||||||
|
// Remove common noise
|
||||||
|
var result = license
|
||||||
|
.Replace("the ", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace(" license", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace(" License", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("(", "")
|
||||||
|
.Replace(")", "")
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidSpdxExpression(string expression)
|
||||||
|
{
|
||||||
|
// Basic validation - SPDX expressions use AND, OR, WITH, parentheses
|
||||||
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must contain valid SPDX identifier characters
|
||||||
|
return SpdxExpressionPattern().IsMatch(expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPlausibleLicenseName(string text)
|
||||||
|
{
|
||||||
|
// Filter out things that are definitely not license names
|
||||||
|
if (text.Length > 100 || text.Length < 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if it looks like a URL
|
||||||
|
if (text.Contains("://") || text.Contains("www."))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if it's a full paragraph
|
||||||
|
if (text.Contains('\n') || text.Split(' ').Length > 10)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeForSpdx(string text)
|
||||||
|
{
|
||||||
|
// SPDX LicenseRef identifiers can only contain alphanumeric, ".", "-"
|
||||||
|
var sanitized = new char[text.Length];
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var c = text[i];
|
||||||
|
if (char.IsLetterOrDigit(c) || c == '.' || c == '-')
|
||||||
|
{
|
||||||
|
sanitized[i] = c;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sanitized[i] = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(sanitized).Trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^MIT(\s|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex MitPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"Apache\s*(Software\s*)?(License\s*)?(Version\s*)?(2\.?0?)?", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex ApachePattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"BSD[\s\-]?(?<clauses>[234])?\s*[\-]?\s*Clause", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex BsdPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(GNU\s*)?(General\s*)?Public\s*License[\s,]*(v|version)?[\s]*(?<version>[23](\.0)?)?(?<orlater>\+|\s*or\s*later)?", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex GplPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(GNU\s*)?Lesser\s*(General\s*)?Public\s*License[\s,]*(v|version)?[\s]*(?<version>[23](\.0|\.1)?)?", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex LgplPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^[A-Za-z0-9.\-\+ ]+(\s+(AND|OR|WITH)\s+[A-Za-z0-9.\-\+ ]+)*$", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex SpdxExpressionPattern();
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Vendoring;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects vendored (bundled) packages inside Python packages.
|
||||||
|
/// Python's equivalent of Java's shaded JAR detection.
|
||||||
|
/// Common patterns: pip._vendor, requests.packages, certifi bundled certs.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class VendoredPackageDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Common vendoring directory patterns.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] VendorDirectoryPatterns =
|
||||||
|
[
|
||||||
|
"_vendor",
|
||||||
|
"_vendored",
|
||||||
|
"vendor",
|
||||||
|
"vendored",
|
||||||
|
"extern",
|
||||||
|
"external",
|
||||||
|
"third_party",
|
||||||
|
"thirdparty",
|
||||||
|
"packages", // Old requests pattern
|
||||||
|
"lib", // Sometimes used for bundled libs
|
||||||
|
"bundled"
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Well-known vendored packages in the Python ecosystem.
|
||||||
|
/// Maps parent package to expected vendored packages.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<string, string[]> KnownVendoredPackages =
|
||||||
|
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["pip"] = ["certifi", "chardet", "colorama", "distlib", "html5lib", "idna", "msgpack",
|
||||||
|
"packaging", "pep517", "pkg_resources", "platformdirs", "pygments", "pyparsing",
|
||||||
|
"requests", "resolvelib", "rich", "setuptools", "six", "tenacity", "tomli",
|
||||||
|
"truststore", "typing_extensions", "urllib3", "webencodings"],
|
||||||
|
["setuptools"] = ["more_itertools", "ordered_set", "packaging", "pyparsing"],
|
||||||
|
["requests"] = ["urllib3", "chardet", "idna", "certifi"],
|
||||||
|
["urllib3"] = ["six"],
|
||||||
|
["virtualenv"] = ["distlib", "filelock", "platformdirs", "six"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a package for vendored dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<VendoringAnalysis> AnalyzeAsync(
|
||||||
|
PythonVirtualFileSystem vfs,
|
||||||
|
PythonPackageInfo package,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(vfs);
|
||||||
|
ArgumentNullException.ThrowIfNull(package);
|
||||||
|
|
||||||
|
var markers = new List<string>();
|
||||||
|
var embeddedPackages = new List<EmbeddedPackage>();
|
||||||
|
var vendoredPaths = new List<string>();
|
||||||
|
|
||||||
|
// Get package installation directory
|
||||||
|
var packageDir = GetPackageDirectory(package);
|
||||||
|
if (string.IsNullOrEmpty(packageDir))
|
||||||
|
{
|
||||||
|
return VendoringAnalysis.NotVendored(package.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan for vendor directories
|
||||||
|
foreach (var vendorPattern in VendorDirectoryPatterns)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var vendorPaths = await FindVendorDirectoriesAsync(vfs, packageDir, vendorPattern, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var vendorPath in vendorPaths)
|
||||||
|
{
|
||||||
|
markers.Add($"vendor-directory:{vendorPattern}");
|
||||||
|
vendoredPaths.Add(vendorPath);
|
||||||
|
|
||||||
|
// Extract embedded package info
|
||||||
|
var embedded = await ExtractEmbeddedPackagesAsync(vfs, vendorPath, package.Name, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
embeddedPackages.AddRange(embedded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for well-known vendored packages
|
||||||
|
if (KnownVendoredPackages.TryGetValue(package.NormalizedName, out var expectedVendored))
|
||||||
|
{
|
||||||
|
var foundExpected = embeddedPackages
|
||||||
|
.Where(e => expectedVendored.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.Select(e => e.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (foundExpected.Count > 0)
|
||||||
|
{
|
||||||
|
markers.Add("known-vendored-package");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check RECORD file for vendor paths
|
||||||
|
if (package.RecordFiles.Length > 0)
|
||||||
|
{
|
||||||
|
var vendorRecords = package.RecordFiles
|
||||||
|
.Where(r => VendorDirectoryPatterns.Any(p =>
|
||||||
|
r.Path.Contains($"/{p}/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
r.Path.Contains($"\\{p}\\", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (vendorRecords.Count > 0)
|
||||||
|
{
|
||||||
|
markers.Add("record-vendor-entries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate confidence
|
||||||
|
var confidence = CalculateConfidence(markers, embeddedPackages.Count);
|
||||||
|
|
||||||
|
return new VendoringAnalysis(
|
||||||
|
package.Name,
|
||||||
|
confidence >= VendoringConfidence.Low, // Any confidence > None indicates vendoring
|
||||||
|
confidence,
|
||||||
|
[.. markers.Distinct().OrderBy(m => m, StringComparer.Ordinal)],
|
||||||
|
[.. embeddedPackages.OrderBy(e => e.Name, StringComparer.Ordinal)],
|
||||||
|
[.. vendoredPaths.Distinct().OrderBy(p => p, StringComparer.Ordinal)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes all packages in a discovery result for vendoring.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<ImmutableArray<VendoringAnalysis>> AnalyzeAllAsync(
|
||||||
|
PythonVirtualFileSystem vfs,
|
||||||
|
PythonPackageDiscoveryResult discoveryResult,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var results = new List<VendoringAnalysis>();
|
||||||
|
|
||||||
|
foreach (var package in discoveryResult.Packages)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var analysis = await AnalyzeAsync(vfs, package, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (analysis.IsVendored)
|
||||||
|
{
|
||||||
|
results.Add(analysis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. results];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetPackageDirectory(PythonPackageInfo package)
|
||||||
|
{
|
||||||
|
// The package module directory is typically in the same directory as the dist-info,
|
||||||
|
// with the same name as the package (normalized to lowercase with underscores).
|
||||||
|
// E.g., dist-info at "site-packages/pip-23.0.dist-info" means package at "site-packages/pip/"
|
||||||
|
|
||||||
|
string? baseDir = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(package.MetadataPath))
|
||||||
|
{
|
||||||
|
// Get the directory containing dist-info (usually site-packages)
|
||||||
|
baseDir = Path.GetDirectoryName(package.MetadataPath);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(package.Location))
|
||||||
|
{
|
||||||
|
baseDir = package.Location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(baseDir))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The package directory is baseDir + package module name
|
||||||
|
// Use the first top-level module if available, otherwise use the normalized package name
|
||||||
|
var moduleName = package.TopLevelModules.Length > 0
|
||||||
|
? package.TopLevelModules[0]
|
||||||
|
: package.NormalizedName;
|
||||||
|
|
||||||
|
return Path.Combine(baseDir, moduleName).Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<string>> FindVendorDirectoriesAsync(
|
||||||
|
PythonVirtualFileSystem vfs,
|
||||||
|
string baseDir,
|
||||||
|
string vendorPattern,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var results = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check for direct vendor directory under package
|
||||||
|
foreach (var file in vfs.Files)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var relativePath = GetRelativePath(baseDir, file.VirtualPath);
|
||||||
|
if (string.IsNullOrEmpty(relativePath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for vendor directory pattern in path
|
||||||
|
var parts = relativePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
for (int i = 0; i < parts.Length - 1; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(parts[i], vendorPattern, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Found vendor directory
|
||||||
|
var vendorPath = string.Join("/", parts.Take(i + 1));
|
||||||
|
var fullVendorPath = Path.Combine(baseDir, vendorPath).Replace('\\', '/');
|
||||||
|
|
||||||
|
if (!results.Contains(fullVendorPath, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
results.Add(fullVendorPath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore errors during directory scanning
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask; // Keep async signature for future enhancements
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<EmbeddedPackage>> ExtractEmbeddedPackagesAsync(
|
||||||
|
PythonVirtualFileSystem vfs,
|
||||||
|
string vendorPath,
|
||||||
|
string parentPackage,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var packages = new List<EmbeddedPackage>();
|
||||||
|
var seenPackages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Find all Python packages in vendor directory
|
||||||
|
foreach (var file in vfs.Files)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!file.VirtualPath.StartsWith(vendorPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = file.VirtualPath[(vendorPath.Length + 1)..];
|
||||||
|
var parts = relativePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
if (parts.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the package name (first directory or .py file)
|
||||||
|
var packageName = parts[0];
|
||||||
|
|
||||||
|
// Handle .py files (single-file modules)
|
||||||
|
if (packageName.EndsWith(".py", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
packageName = packageName[..^3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip __pycache__ and other internal directories
|
||||||
|
if (packageName.StartsWith("__") || packageName.StartsWith("."))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenPackages.Add(packageName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract version from __init__.py or version.py
|
||||||
|
var version = await ExtractVersionAsync(vfs, vendorPath, packageName, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Try to find license
|
||||||
|
var license = await ExtractLicenseAsync(vfs, vendorPath, packageName, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
packages.Add(new EmbeddedPackage(
|
||||||
|
packageName,
|
||||||
|
version,
|
||||||
|
license,
|
||||||
|
Path.Combine(vendorPath, packageName).Replace('\\', '/'),
|
||||||
|
parentPackage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore errors during extraction
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ExtractVersionAsync(
|
||||||
|
PythonVirtualFileSystem vfs,
|
||||||
|
string vendorPath,
|
||||||
|
string packageName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Common locations for version information
|
||||||
|
var versionFiles = new[]
|
||||||
|
{
|
||||||
|
$"{vendorPath}/{packageName}/__init__.py",
|
||||||
|
$"{vendorPath}/{packageName}/_version.py",
|
||||||
|
$"{vendorPath}/{packageName}/version.py",
|
||||||
|
$"{vendorPath}/{packageName}/__version__.py"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var versionFile in versionFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = await vfs.OpenReadAsync(versionFile, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (stream is null) continue;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Look for __version__ = "x.y.z"
|
||||||
|
var match = VersionPattern().Match(content);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
return match.Groups["version"].Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Continue to next file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ExtractLicenseAsync(
|
||||||
|
PythonVirtualFileSystem vfs,
|
||||||
|
string vendorPath,
|
||||||
|
string packageName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Common license file locations
|
||||||
|
var licenseFiles = new[]
|
||||||
|
{
|
||||||
|
$"{vendorPath}/{packageName}/LICENSE",
|
||||||
|
$"{vendorPath}/{packageName}/LICENSE.txt",
|
||||||
|
$"{vendorPath}/{packageName}/LICENSE.md",
|
||||||
|
$"{vendorPath}/{packageName}/COPYING"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var licenseFile in licenseFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = await vfs.OpenReadAsync(licenseFile, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (stream is null) continue;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var firstLine = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Try to identify license from content
|
||||||
|
if (firstLine?.Contains("MIT", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
return "MIT";
|
||||||
|
}
|
||||||
|
if (firstLine?.Contains("Apache", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
return "Apache-2.0";
|
||||||
|
}
|
||||||
|
if (firstLine?.Contains("BSD", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
return "BSD-3-Clause";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown (license file present)";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Continue to next file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetRelativePath(string basePath, string fullPath)
|
||||||
|
{
|
||||||
|
basePath = basePath.Replace('\\', '/').TrimEnd('/');
|
||||||
|
fullPath = fullPath.Replace('\\', '/');
|
||||||
|
|
||||||
|
if (fullPath.StartsWith(basePath + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return fullPath[(basePath.Length + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendoringConfidence CalculateConfidence(List<string> markers, int embeddedCount)
|
||||||
|
{
|
||||||
|
var score = 0;
|
||||||
|
|
||||||
|
// Strong indicators
|
||||||
|
if (markers.Contains("known-vendored-package")) score += 3;
|
||||||
|
if (markers.Contains("record-vendor-entries")) score += 2;
|
||||||
|
|
||||||
|
// Vendor directory presence
|
||||||
|
var vendorDirs = markers.Count(m => m.StartsWith("vendor-directory:"));
|
||||||
|
score += vendorDirs;
|
||||||
|
|
||||||
|
// Embedded package count
|
||||||
|
if (embeddedCount > 5) score += 2;
|
||||||
|
else if (embeddedCount > 1) score += 1;
|
||||||
|
|
||||||
|
return score switch
|
||||||
|
{
|
||||||
|
>= 4 => VendoringConfidence.High,
|
||||||
|
>= 2 => VendoringConfidence.Medium,
|
||||||
|
>= 1 => VendoringConfidence.Low,
|
||||||
|
_ => VendoringConfidence.None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern to match __version__ = "x.y.z" or VERSION = "x.y.z"
|
||||||
|
[GeneratedRegex(
|
||||||
|
@"(?:__version__|VERSION)\s*=\s*['""](?<version>[^'""]+)['""]",
|
||||||
|
RegexOptions.Compiled)]
|
||||||
|
private static partial Regex VersionPattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of vendoring analysis for a single package.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record VendoringAnalysis(
|
||||||
|
string PackageName,
|
||||||
|
bool IsVendored,
|
||||||
|
VendoringConfidence Confidence,
|
||||||
|
ImmutableArray<string> Markers,
|
||||||
|
ImmutableArray<EmbeddedPackage> EmbeddedPackages,
|
||||||
|
ImmutableArray<string> VendorPaths)
|
||||||
|
{
|
||||||
|
public static VendoringAnalysis NotVendored(string packageName) => new(
|
||||||
|
packageName,
|
||||||
|
false,
|
||||||
|
VendoringConfidence.None,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of embedded packages.
|
||||||
|
/// </summary>
|
||||||
|
public int EmbeddedCount => EmbeddedPackages.Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the embedded packages as a comma-separated list.
|
||||||
|
/// </summary>
|
||||||
|
public string GetEmbeddedPackageList()
|
||||||
|
=> string.Join(",", EmbeddedPackages.Select(p => p.NameWithVersion));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets PURLs for all embedded packages.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> GetEmbeddedPurls()
|
||||||
|
=> EmbeddedPackages.Select(p => p.Purl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a package embedded/vendored inside another package.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record EmbeddedPackage(
|
||||||
|
string Name,
|
||||||
|
string? Version,
|
||||||
|
string? License,
|
||||||
|
string Path,
|
||||||
|
string ParentPackage)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the name with version if available.
|
||||||
|
/// </summary>
|
||||||
|
public string NameWithVersion => Version is not null ? $"{Name}@{Version}" : Name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the PURL for this embedded package.
|
||||||
|
/// </summary>
|
||||||
|
public string Purl => Version is not null
|
||||||
|
? $"pkg:pypi/{NormalizeName(Name)}@{Version}"
|
||||||
|
: $"pkg:pypi/{NormalizeName(Name)}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a qualified name including the parent package.
|
||||||
|
/// </summary>
|
||||||
|
public string QualifiedName => $"{ParentPackage}._vendor.{Name}";
|
||||||
|
|
||||||
|
private static string NormalizeName(string name) =>
|
||||||
|
name.ToLowerInvariant().Replace('_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confidence level for vendoring detection.
|
||||||
|
/// </summary>
|
||||||
|
internal enum VendoringConfidence
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Low = 1,
|
||||||
|
Medium = 2,
|
||||||
|
High = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"@company/internal-pkg@1.0.0": ["https://npm.company.com/@company/internal-pkg/-/internal-pkg-1.0.0.tgz", "sha512-customhash123=="]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[install.scopes]
|
||||||
|
"@company" = "https://npm.company.com/"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"analyzerId": "bun",
|
||||||
|
"componentKey": "purl::pkg:npm/%40company/internal-pkg@1.0.0",
|
||||||
|
"purl": "pkg:npm/%40company/internal-pkg@1.0.0",
|
||||||
|
"name": "@company/internal-pkg",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "npm",
|
||||||
|
"usedByEntrypoint": false,
|
||||||
|
"metadata": {
|
||||||
|
"customRegistry": "https://npm.company.com/",
|
||||||
|
"direct": "true",
|
||||||
|
"integrity": "sha512-customhash123==",
|
||||||
|
"packageManager": "bun",
|
||||||
|
"path": "node_modules/@company/internal-pkg",
|
||||||
|
"resolved": "https://npm.company.com/@company/internal-pkg/-/internal-pkg-1.0.0.tgz",
|
||||||
|
"source": "node_modules"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"source": "node_modules",
|
||||||
|
"locator": "node_modules/@company/internal-pkg/package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "integrity",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "sha512-customhash123=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "resolved",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "https://npm.company.com/@company/internal-pkg/-/internal-pkg-1.0.0.tgz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "custom-registry-fixture",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@company/internal-pkg": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"debug@4.3.4": ["https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", {"ms": "^2.1.2"}],
|
||||||
|
"ms@2.1.3": ["https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"analyzerId": "bun",
|
||||||
|
"componentKey": "purl::pkg:npm/debug@4.3.4",
|
||||||
|
"purl": "pkg:npm/debug@4.3.4",
|
||||||
|
"name": "debug",
|
||||||
|
"version": "4.3.4",
|
||||||
|
"type": "npm",
|
||||||
|
"usedByEntrypoint": false,
|
||||||
|
"metadata": {
|
||||||
|
"direct": "true",
|
||||||
|
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||||
|
"packageManager": "bun",
|
||||||
|
"path": "node_modules/debug",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||||
|
"source": "node_modules"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"source": "node_modules",
|
||||||
|
"locator": "node_modules/debug/package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "integrity",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "resolved",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"analyzerId": "bun",
|
||||||
|
"componentKey": "purl::pkg:npm/ms@2.1.3",
|
||||||
|
"purl": "pkg:npm/ms@2.1.3",
|
||||||
|
"name": "ms",
|
||||||
|
"version": "2.1.3",
|
||||||
|
"type": "npm",
|
||||||
|
"usedByEntrypoint": false,
|
||||||
|
"metadata": {
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"packageManager": "bun",
|
||||||
|
"path": "node_modules/ms",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"source": "node_modules"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"source": "node_modules",
|
||||||
|
"locator": "node_modules/ms/package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "integrity",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "resolved",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "deep-tree-fixture",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"my-git-pkg@1.0.0": ["git+https://github.com/user/my-git-pkg.git#abc123def456", null]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"analyzerId": "bun",
|
||||||
|
"componentKey": "purl::pkg:npm/my-git-pkg@1.0.0",
|
||||||
|
"purl": "pkg:npm/my-git-pkg@1.0.0",
|
||||||
|
"name": "my-git-pkg",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "npm",
|
||||||
|
"usedByEntrypoint": false,
|
||||||
|
"metadata": {
|
||||||
|
"direct": "true",
|
||||||
|
"gitCommit": "abc123def456",
|
||||||
|
"packageManager": "bun",
|
||||||
|
"path": "node_modules/my-git-pkg",
|
||||||
|
"resolved": "git+https://github.com/user/my-git-pkg.git#abc123def456",
|
||||||
|
"source": "node_modules",
|
||||||
|
"sourceType": "git",
|
||||||
|
"specifier": "git+https://github.com/user/my-git-pkg.git#abc123def456"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"source": "node_modules",
|
||||||
|
"locator": "node_modules/my-git-pkg/package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "resolved",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "git+https://github.com/user/my-git-pkg.git#abc123def456"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "git-dependencies-fixture",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"my-git-pkg": "github:user/my-git-pkg#v1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi+8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7+D9bF8Q=="]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"analyzerId": "bun",
|
||||||
|
"componentKey": "purl::pkg:npm/lodash@4.17.21",
|
||||||
|
"purl": "pkg:npm/lodash@4.17.21",
|
||||||
|
"name": "lodash",
|
||||||
|
"version": "4.17.21",
|
||||||
|
"type": "npm",
|
||||||
|
"usedByEntrypoint": false,
|
||||||
|
"metadata": {
|
||||||
|
"direct": "true",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi+8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7+D9bF8Q==",
|
||||||
|
"packageManager": "bun",
|
||||||
|
"patchFile": "patches/lodash@4.17.21.patch",
|
||||||
|
"patched": "true",
|
||||||
|
"path": "node_modules/lodash",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"source": "node_modules"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"source": "node_modules",
|
||||||
|
"locator": "node_modules/lodash/package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "integrity",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi+8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7+D9bF8Q=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "resolved",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "patched-packages-fixture",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"lodash@4.17.21": "patches/lodash@4.17.21.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
--- a/index.js
|
||||||
|
+++ b/index.js
|
||||||
|
@@ -1 +1 @@
|
||||||
|
-module.exports = require('./lodash');
|
||||||
|
+module.exports = require('./lodash-patched');
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"@babel/core@7.24.0": ["https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw=="],
|
||||||
|
"@types/node@20.11.0": ["https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxXjVJa7b8XWCF/wPH2E/0Vz9e+V1B3eXX0WCw+INcAobvUag=="]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"analyzerId": "bun",
|
||||||
|
"componentKey": "purl::pkg:npm/%40babel/core@7.24.0",
|
||||||
|
"purl": "pkg:npm/%40babel/core@7.24.0",
|
||||||
|
"name": "@babel/core",
|
||||||
|
"version": "7.24.0",
|
||||||
|
"type": "npm",
|
||||||
|
"usedByEntrypoint": false,
|
||||||
|
"metadata": {
|
||||||
|
"direct": "true",
|
||||||
|
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
|
||||||
|
"packageManager": "bun",
|
||||||
|
"path": "node_modules/@babel/core",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
|
||||||
|
"source": "node_modules"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"source": "node_modules",
|
||||||
|
"locator": "node_modules/@babel/core/package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "integrity",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "resolved",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"analyzerId": "bun",
|
||||||
|
"componentKey": "purl::pkg:npm/%40types/node@20.11.0",
|
||||||
|
"purl": "pkg:npm/%40types/node@20.11.0",
|
||||||
|
"name": "@types/node",
|
||||||
|
"version": "20.11.0",
|
||||||
|
"type": "npm",
|
||||||
|
"usedByEntrypoint": false,
|
||||||
|
"metadata": {
|
||||||
|
"direct": "true",
|
||||||
|
"integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxXjVJa7b8XWCF/wPH2E/0Vz9e+V1B3eXX0WCw+INcAobvUag==",
|
||||||
|
"packageManager": "bun",
|
||||||
|
"path": "node_modules/@types/node",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz",
|
||||||
|
"source": "node_modules"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"source": "node_modules",
|
||||||
|
"locator": "node_modules/@types/node/package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "integrity",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxXjVJa7b8XWCF/wPH2E/0Vz9e+V1B3eXX0WCw+INcAobvUag=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "metadata",
|
||||||
|
"source": "resolved",
|
||||||
|
"locator": "bun.lock",
|
||||||
|
"value": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "scoped-packages-fixture",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.24.0",
|
||||||
|
"@types/node": "^20.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
|
||||||
|
|
||||||
|
public sealed class BunConfigHelperTests
|
||||||
|
{
|
||||||
|
private readonly string _tempDir;
|
||||||
|
|
||||||
|
public BunConfigHelperTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), $"bun-config-test-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_tempDir))
|
||||||
|
{
|
||||||
|
Directory.Delete(_tempDir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region ParseConfig Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseConfig_MissingFile_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.ParseConfig(_tempDir);
|
||||||
|
|
||||||
|
Assert.Null(result.DefaultRegistry);
|
||||||
|
Assert.Empty(result.ScopeRegistries);
|
||||||
|
Assert.False(result.HasCustomRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseConfig_DefaultRegistry_ReturnsUrl()
|
||||||
|
{
|
||||||
|
var bunfig = """
|
||||||
|
[install]
|
||||||
|
registry = "https://npm.company.com/"
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
|
||||||
|
|
||||||
|
var result = BunConfigHelper.ParseConfig(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal("https://npm.company.com/", result.DefaultRegistry);
|
||||||
|
Assert.True(result.HasCustomRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseConfig_ScopedRegistries_ReturnsMappings()
|
||||||
|
{
|
||||||
|
var bunfig = """
|
||||||
|
[install.scopes]
|
||||||
|
"@company" = "https://npm.company.com/"
|
||||||
|
"@internal" = "https://internal.registry.com/"
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
|
||||||
|
|
||||||
|
var result = BunConfigHelper.ParseConfig(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.ScopeRegistries.Count);
|
||||||
|
Assert.Equal("https://npm.company.com/", result.ScopeRegistries["@company"]);
|
||||||
|
Assert.Equal("https://internal.registry.com/", result.ScopeRegistries["@internal"]);
|
||||||
|
Assert.True(result.HasCustomRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseConfig_InlineTableFormat_ExtractsUrl()
|
||||||
|
{
|
||||||
|
var bunfig = """
|
||||||
|
[install.scopes]
|
||||||
|
"@company" = { url = "https://npm.company.com/" }
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
|
||||||
|
|
||||||
|
var result = BunConfigHelper.ParseConfig(_tempDir);
|
||||||
|
|
||||||
|
Assert.Single(result.ScopeRegistries);
|
||||||
|
Assert.Equal("https://npm.company.com/", result.ScopeRegistries["@company"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseConfig_Comments_IgnoresComments()
|
||||||
|
{
|
||||||
|
var bunfig = """
|
||||||
|
# This is a comment
|
||||||
|
[install]
|
||||||
|
# registry for npm packages
|
||||||
|
registry = "https://npm.company.com/"
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
|
||||||
|
|
||||||
|
var result = BunConfigHelper.ParseConfig(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal("https://npm.company.com/", result.DefaultRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseConfig_EmptyFile_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), "");
|
||||||
|
|
||||||
|
var result = BunConfigHelper.ParseConfig(_tempDir);
|
||||||
|
|
||||||
|
Assert.Null(result.DefaultRegistry);
|
||||||
|
Assert.Empty(result.ScopeRegistries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseConfig_BothDefaultAndScoped_ReturnsBoth()
|
||||||
|
{
|
||||||
|
var bunfig = """
|
||||||
|
[install]
|
||||||
|
registry = "https://default.registry.com/"
|
||||||
|
|
||||||
|
[install.scopes]
|
||||||
|
"@company" = "https://npm.company.com/"
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
|
||||||
|
|
||||||
|
var result = BunConfigHelper.ParseConfig(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal("https://default.registry.com/", result.DefaultRegistry);
|
||||||
|
Assert.Single(result.ScopeRegistries);
|
||||||
|
Assert.Equal("https://npm.company.com/", result.ScopeRegistries["@company"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region StripQuotes Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripQuotes_DoubleQuotes_RemovesQuotes()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.StripQuotes("\"hello world\"");
|
||||||
|
|
||||||
|
Assert.Equal("hello world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripQuotes_SingleQuotes_RemovesQuotes()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.StripQuotes("'hello world'");
|
||||||
|
|
||||||
|
Assert.Equal("hello world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripQuotes_NoQuotes_ReturnsUnchanged()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.StripQuotes("hello world");
|
||||||
|
|
||||||
|
Assert.Equal("hello world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripQuotes_MismatchedQuotes_ReturnsUnchanged()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.StripQuotes("\"hello world'");
|
||||||
|
|
||||||
|
Assert.Equal("\"hello world'", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripQuotes_EmptyString_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.StripQuotes("");
|
||||||
|
|
||||||
|
Assert.Equal("", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripQuotes_SingleCharacter_ReturnsUnchanged()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.StripQuotes("a");
|
||||||
|
|
||||||
|
Assert.Equal("a", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ExtractRegistryUrl Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractRegistryUrl_DirectUrl_ReturnsUrl()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.ExtractRegistryUrl("https://npm.company.com/");
|
||||||
|
|
||||||
|
Assert.Equal("https://npm.company.com/", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractRegistryUrl_InlineTable_ExtractsUrl()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.ExtractRegistryUrl("{ url = \"https://npm.company.com/\" }");
|
||||||
|
|
||||||
|
Assert.Equal("https://npm.company.com/", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractRegistryUrl_InlineTableSingleQuotes_ExtractsUrl()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.ExtractRegistryUrl("{ url = 'https://npm.company.com/' }");
|
||||||
|
|
||||||
|
Assert.Equal("https://npm.company.com/", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractRegistryUrl_InvalidFormat_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.ExtractRegistryUrl("not-a-url");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractRegistryUrl_HttpUrl_ReturnsUrl()
|
||||||
|
{
|
||||||
|
var result = BunConfigHelper.ExtractRegistryUrl("http://internal.registry.local/");
|
||||||
|
|
||||||
|
Assert.Equal("http://internal.registry.local/", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
|
||||||
|
|
||||||
|
public sealed class BunLockParserTests
|
||||||
|
{
|
||||||
|
#region ParsePackageKey Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePackageKey_ScopedPackage_ReturnsCorrectNameAndVersion()
|
||||||
|
{
|
||||||
|
var (name, version) = BunLockParser.ParsePackageKey("@babel/core@7.24.0");
|
||||||
|
|
||||||
|
Assert.Equal("@babel/core", name);
|
||||||
|
Assert.Equal("7.24.0", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePackageKey_UnscopedPackage_ReturnsCorrectNameAndVersion()
|
||||||
|
{
|
||||||
|
var (name, version) = BunLockParser.ParsePackageKey("lodash@4.17.21");
|
||||||
|
|
||||||
|
Assert.Equal("lodash", name);
|
||||||
|
Assert.Equal("4.17.21", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePackageKey_InvalidFormat_NoAtSymbol_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var (name, version) = BunLockParser.ParsePackageKey("lodash");
|
||||||
|
|
||||||
|
Assert.Empty(name);
|
||||||
|
Assert.Empty(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePackageKey_InvalidFormat_OnlyScope_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var (name, version) = BunLockParser.ParsePackageKey("@babel");
|
||||||
|
|
||||||
|
Assert.Empty(name);
|
||||||
|
Assert.Empty(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePackageKey_ScopedPackageWithComplexVersion_ReturnsCorrectParts()
|
||||||
|
{
|
||||||
|
var (name, version) = BunLockParser.ParsePackageKey("@types/node@20.11.24");
|
||||||
|
|
||||||
|
Assert.Equal("@types/node", name);
|
||||||
|
Assert.Equal("20.11.24", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePackageKey_PreReleaseVersion_ReturnsCorrectParts()
|
||||||
|
{
|
||||||
|
var (name, version) = BunLockParser.ParsePackageKey("typescript@5.4.0-beta");
|
||||||
|
|
||||||
|
Assert.Equal("typescript", name);
|
||||||
|
Assert.Equal("5.4.0-beta", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ClassifyResolvedUrl Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_GitPlusHttps_ReturnsGit()
|
||||||
|
{
|
||||||
|
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("git+https://github.com/user/repo.git#abc123");
|
||||||
|
|
||||||
|
Assert.Equal("git", sourceType);
|
||||||
|
Assert.Equal("abc123", gitCommit);
|
||||||
|
Assert.Equal("git+https://github.com/user/repo.git#abc123", specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_GitPlusSsh_ReturnsGit()
|
||||||
|
{
|
||||||
|
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("git+ssh://git@github.com/user/repo.git#v1.0.0");
|
||||||
|
|
||||||
|
Assert.Equal("git", sourceType);
|
||||||
|
Assert.Equal("v1.0.0", gitCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_GithubShorthand_ReturnsGit()
|
||||||
|
{
|
||||||
|
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("github:user/repo#main");
|
||||||
|
|
||||||
|
Assert.Equal("git", sourceType);
|
||||||
|
Assert.Equal("main", gitCommit);
|
||||||
|
Assert.Equal("github:user/repo#main", specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_GitlabShorthand_ReturnsGit()
|
||||||
|
{
|
||||||
|
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("gitlab:user/repo#v2.0.0");
|
||||||
|
|
||||||
|
Assert.Equal("git", sourceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_BitbucketShorthand_ReturnsGit()
|
||||||
|
{
|
||||||
|
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("bitbucket:user/repo#feature");
|
||||||
|
|
||||||
|
Assert.Equal("git", sourceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_TarballUrl_ReturnsTarball()
|
||||||
|
{
|
||||||
|
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("https://example.com/pkg-1.0.0.tgz");
|
||||||
|
|
||||||
|
Assert.Equal("tarball", sourceType);
|
||||||
|
Assert.Null(gitCommit);
|
||||||
|
Assert.Equal("https://example.com/pkg-1.0.0.tgz", specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_TarGzUrl_ReturnsTarball()
|
||||||
|
{
|
||||||
|
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("https://example.com/pkg-1.0.0.tar.gz");
|
||||||
|
|
||||||
|
Assert.Equal("tarball", sourceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_NpmRegistryTgz_ReturnsNpm()
|
||||||
|
{
|
||||||
|
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz");
|
||||||
|
|
||||||
|
Assert.Equal("npm", sourceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_FileProtocol_ReturnsFile()
|
||||||
|
{
|
||||||
|
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("file:./local-pkg");
|
||||||
|
|
||||||
|
Assert.Equal("file", sourceType);
|
||||||
|
Assert.Null(gitCommit);
|
||||||
|
Assert.Equal("file:./local-pkg", specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_LinkProtocol_ReturnsLink()
|
||||||
|
{
|
||||||
|
var (sourceType, _, specifier) = BunLockParser.ClassifyResolvedUrl("link:../packages/shared");
|
||||||
|
|
||||||
|
Assert.Equal("link", sourceType);
|
||||||
|
Assert.Equal("link:../packages/shared", specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_WorkspaceProtocol_ReturnsWorkspace()
|
||||||
|
{
|
||||||
|
var (sourceType, _, specifier) = BunLockParser.ClassifyResolvedUrl("workspace:*");
|
||||||
|
|
||||||
|
Assert.Equal("workspace", sourceType);
|
||||||
|
Assert.Equal("workspace:*", specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_NpmRegistry_ReturnsNpm()
|
||||||
|
{
|
||||||
|
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz");
|
||||||
|
|
||||||
|
Assert.Equal("npm", sourceType);
|
||||||
|
Assert.Null(gitCommit);
|
||||||
|
Assert.Null(specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyResolvedUrl_NullOrEmpty_ReturnsNpm()
|
||||||
|
{
|
||||||
|
var (sourceType1, _, _) = BunLockParser.ClassifyResolvedUrl(null);
|
||||||
|
var (sourceType2, _, _) = BunLockParser.ClassifyResolvedUrl("");
|
||||||
|
|
||||||
|
Assert.Equal("npm", sourceType1);
|
||||||
|
Assert.Equal("npm", sourceType2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ExtractGitCommit Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractGitCommit_HashFragment_ReturnsCommit()
|
||||||
|
{
|
||||||
|
var commit = BunLockParser.ExtractGitCommit("git+https://github.com/user/repo.git#abc123def");
|
||||||
|
|
||||||
|
Assert.Equal("abc123def", commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractGitCommit_NoFragment_ReturnsNull()
|
||||||
|
{
|
||||||
|
var commit = BunLockParser.ExtractGitCommit("git+https://github.com/user/repo.git");
|
||||||
|
|
||||||
|
Assert.Null(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractGitCommit_EmptyFragment_ReturnsNull()
|
||||||
|
{
|
||||||
|
var commit = BunLockParser.ExtractGitCommit("github:user/repo#");
|
||||||
|
|
||||||
|
Assert.Null(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractGitCommit_TagName_ReturnsTag()
|
||||||
|
{
|
||||||
|
var commit = BunLockParser.ExtractGitCommit("github:user/repo#v1.2.3");
|
||||||
|
|
||||||
|
Assert.Equal("v1.2.3", commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Parse Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_EmptyContent_ReturnsEmptyData()
|
||||||
|
{
|
||||||
|
var result = BunLockParser.Parse("");
|
||||||
|
|
||||||
|
Assert.Empty(result.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_WhitespaceContent_ReturnsEmptyData()
|
||||||
|
{
|
||||||
|
var result = BunLockParser.Parse(" \n\t ");
|
||||||
|
|
||||||
|
Assert.Empty(result.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_MalformedJson_ReturnsEmptyData()
|
||||||
|
{
|
||||||
|
var result = BunLockParser.Parse("{ invalid json }");
|
||||||
|
|
||||||
|
Assert.Empty(result.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_JsoncComments_IgnoresCommentsAndParses()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
// This is a comment
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-abc"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
Assert.Equal("lodash", result.Entries[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_TrailingCommas_ParsesSuccessfully()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-abc"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ArrayFormat_ExtractsResolvedAndIntegrity()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"ms@2.1.3": ["https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "sha512-6FlzubTLZG3J2a"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
var entry = result.Entries[0];
|
||||||
|
Assert.Equal("ms", entry.Name);
|
||||||
|
Assert.Equal("2.1.3", entry.Version);
|
||||||
|
Assert.Equal("https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", entry.Resolved);
|
||||||
|
Assert.Equal("sha512-6FlzubTLZG3J2a", entry.Integrity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ArrayFormat_ExtractsDependencies()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"debug@4.3.4": ["https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "sha512-abc", {"ms": "^2.1.3"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
var entry = result.Entries[0];
|
||||||
|
Assert.Single(entry.Dependencies);
|
||||||
|
Assert.Contains("ms", entry.Dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ObjectFormat_ExtractsDevOptionalPeer()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"typescript@5.4.0": {
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-abc",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
var entry = result.Entries[0];
|
||||||
|
Assert.Equal("typescript", entry.Name);
|
||||||
|
Assert.True(entry.IsDev);
|
||||||
|
Assert.True(entry.IsOptional);
|
||||||
|
Assert.True(entry.IsPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_StringFormat_ExtractsResolved()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"lodash@4.17.21": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
var entry = result.Entries[0];
|
||||||
|
Assert.Equal("lodash", entry.Name);
|
||||||
|
Assert.Equal("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", entry.Resolved);
|
||||||
|
Assert.Null(entry.Integrity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_SkipsRootProjectEntry()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"": {},
|
||||||
|
".": {},
|
||||||
|
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-abc"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
Assert.Equal("lodash", result.Entries[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_MultiplePackages_ReturnsAll()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-lodash"],
|
||||||
|
"ms@2.1.3": ["https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "sha512-ms"],
|
||||||
|
"@babel/core@7.24.0": ["https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "sha512-babel"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Equal(3, result.Entries.Count);
|
||||||
|
Assert.Contains(result.Entries, e => e.Name == "lodash");
|
||||||
|
Assert.Contains(result.Entries, e => e.Name == "ms");
|
||||||
|
Assert.Contains(result.Entries, e => e.Name == "@babel/core");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_GitDependency_ClassifiesCorrectly()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"packages": {
|
||||||
|
"my-pkg@1.0.0": ["git+https://github.com/user/my-pkg.git#abc123", null]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Single(result.Entries);
|
||||||
|
var entry = result.Entries[0];
|
||||||
|
Assert.Equal("git", entry.SourceType);
|
||||||
|
Assert.Equal("abc123", entry.GitCommit);
|
||||||
|
Assert.Equal("git+https://github.com/user/my-pkg.git#abc123", entry.Specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_NoPackagesProperty_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var content = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = BunLockParser.Parse(content);
|
||||||
|
|
||||||
|
Assert.Empty(result.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
|
||||||
|
|
||||||
|
public sealed class BunPackageTests
|
||||||
|
{
|
||||||
|
#region Purl Generation Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromPackageJson_UnscopedPackage_GeneratesCorrectPurl()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
Assert.Equal("pkg:npm/lodash@4.17.21", package.Purl);
|
||||||
|
Assert.Equal("purl::pkg:npm/lodash@4.17.21", package.ComponentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromPackageJson_ScopedPackage_EncodesAtSymbol()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "@babel/core",
|
||||||
|
version: "7.24.0",
|
||||||
|
logicalPath: "node_modules/@babel/core",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
Assert.Equal("pkg:npm/%40babel/core@7.24.0", package.Purl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromPackageJson_ScopedPackageWithSlash_EncodesCorrectly()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "@types/node",
|
||||||
|
version: "20.11.0",
|
||||||
|
logicalPath: "node_modules/@types/node",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
Assert.Equal("pkg:npm/%40types/node@20.11.0", package.Purl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region CreateMetadata Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateMetadata_BasicPackage_ReturnsRequiredKeys()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
|
||||||
|
Assert.True(metadata.ContainsKey("packageManager"));
|
||||||
|
Assert.Equal("bun", metadata["packageManager"]);
|
||||||
|
Assert.True(metadata.ContainsKey("source"));
|
||||||
|
Assert.Equal("node_modules", metadata["source"]);
|
||||||
|
Assert.True(metadata.ContainsKey("path"));
|
||||||
|
Assert.Equal("node_modules/lodash", metadata["path"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateMetadata_AllFieldsSet_ReturnsAllKeys()
|
||||||
|
{
|
||||||
|
var lockEntry = new BunLockEntry
|
||||||
|
{
|
||||||
|
Name = "lodash",
|
||||||
|
Version = "4.17.21",
|
||||||
|
Resolved = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
Integrity = "sha512-abc",
|
||||||
|
IsDev = true,
|
||||||
|
IsOptional = true,
|
||||||
|
IsPeer = true,
|
||||||
|
SourceType = "git",
|
||||||
|
GitCommit = "abc123",
|
||||||
|
Specifier = "github:lodash/lodash#abc123"
|
||||||
|
};
|
||||||
|
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: "node_modules/.bun/lodash@4.17.21",
|
||||||
|
isPrivate: true,
|
||||||
|
lockEntry: lockEntry);
|
||||||
|
|
||||||
|
package.IsDirect = true;
|
||||||
|
package.IsPatched = true;
|
||||||
|
package.PatchFile = "patches/lodash.patch";
|
||||||
|
package.CustomRegistry = "https://npm.company.com/";
|
||||||
|
|
||||||
|
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
|
||||||
|
Assert.Equal("true", metadata["dev"]);
|
||||||
|
Assert.Equal("true", metadata["direct"]);
|
||||||
|
Assert.Equal("true", metadata["optional"]);
|
||||||
|
Assert.Equal("true", metadata["peer"]);
|
||||||
|
Assert.Equal("true", metadata["private"]);
|
||||||
|
Assert.Equal("true", metadata["patched"]);
|
||||||
|
Assert.Equal("patches/lodash.patch", metadata["patchFile"]);
|
||||||
|
Assert.Equal("https://npm.company.com/", metadata["customRegistry"]);
|
||||||
|
Assert.Equal("abc123", metadata["gitCommit"]);
|
||||||
|
Assert.Equal("git", metadata["sourceType"]);
|
||||||
|
Assert.Equal("github:lodash/lodash#abc123", metadata["specifier"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateMetadata_SortedAlphabetically()
|
||||||
|
{
|
||||||
|
var lockEntry = new BunLockEntry
|
||||||
|
{
|
||||||
|
Name = "lodash",
|
||||||
|
Version = "4.17.21",
|
||||||
|
Resolved = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
Integrity = "sha512-abc",
|
||||||
|
IsDev = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: lockEntry);
|
||||||
|
|
||||||
|
package.IsDirect = true;
|
||||||
|
|
||||||
|
var keys = package.CreateMetadata().Select(kvp => kvp.Key).ToList();
|
||||||
|
|
||||||
|
// Verify keys are sorted alphabetically
|
||||||
|
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||||
|
Assert.Equal(sortedKeys, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateMetadata_NormalizesPathSeparators()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules\\lodash",
|
||||||
|
realPath: "node_modules\\.bun\\lodash@4.17.21",
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
|
||||||
|
Assert.Equal("node_modules/lodash", metadata["path"]);
|
||||||
|
Assert.Equal("node_modules/.bun/lodash@4.17.21", metadata["realPath"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateMetadata_MultipleOccurrences_JoinsWithSemicolon()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
package.AddOccurrence("node_modules/lodash");
|
||||||
|
package.AddOccurrence("packages/app/node_modules/lodash");
|
||||||
|
|
||||||
|
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
|
||||||
|
Assert.True(metadata.ContainsKey("occurrences"));
|
||||||
|
Assert.Contains(";", metadata["occurrences"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region CreateEvidence Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateEvidence_WithResolvedAndIntegrity_ReturnsAll()
|
||||||
|
{
|
||||||
|
var lockEntry = new BunLockEntry
|
||||||
|
{
|
||||||
|
Name = "lodash",
|
||||||
|
Version = "4.17.21",
|
||||||
|
Resolved = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
Integrity = "sha512-abc123"
|
||||||
|
};
|
||||||
|
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: lockEntry);
|
||||||
|
|
||||||
|
var evidence = package.CreateEvidence().ToList();
|
||||||
|
|
||||||
|
Assert.Equal(3, evidence.Count);
|
||||||
|
|
||||||
|
// File evidence
|
||||||
|
var fileEvidence = evidence.FirstOrDefault(e => e.Kind == LanguageEvidenceKind.File);
|
||||||
|
Assert.NotNull(fileEvidence);
|
||||||
|
Assert.Equal("node_modules", fileEvidence.Source);
|
||||||
|
Assert.Equal("node_modules/lodash/package.json", fileEvidence.Locator);
|
||||||
|
|
||||||
|
// Resolved evidence
|
||||||
|
var resolvedEvidence = evidence.FirstOrDefault(e => e.Source == "resolved");
|
||||||
|
Assert.NotNull(resolvedEvidence);
|
||||||
|
Assert.Equal(LanguageEvidenceKind.Metadata, resolvedEvidence.Kind);
|
||||||
|
Assert.Equal("bun.lock", resolvedEvidence.Locator);
|
||||||
|
Assert.Equal("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", resolvedEvidence.Value);
|
||||||
|
|
||||||
|
// Integrity evidence
|
||||||
|
var integrityEvidence = evidence.FirstOrDefault(e => e.Source == "integrity");
|
||||||
|
Assert.NotNull(integrityEvidence);
|
||||||
|
Assert.Equal("sha512-abc123", integrityEvidence.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateEvidence_NoLockEntry_ReturnsOnlyFileEvidence()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
var evidence = package.CreateEvidence().ToList();
|
||||||
|
|
||||||
|
Assert.Single(evidence);
|
||||||
|
Assert.Equal(LanguageEvidenceKind.File, evidence[0].Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region FromLockEntry Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromLockEntry_CreatesPackageWithAllProperties()
|
||||||
|
{
|
||||||
|
var lockEntry = new BunLockEntry
|
||||||
|
{
|
||||||
|
Name = "ms",
|
||||||
|
Version = "2.1.3",
|
||||||
|
Resolved = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
Integrity = "sha512-6FlzubTLZG3J2a",
|
||||||
|
IsDev = true,
|
||||||
|
IsOptional = false,
|
||||||
|
IsPeer = false,
|
||||||
|
SourceType = "npm",
|
||||||
|
Dependencies = new List<string> { "debug" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var package = BunPackage.FromLockEntry(lockEntry, "bun.lock");
|
||||||
|
|
||||||
|
Assert.Equal("ms", package.Name);
|
||||||
|
Assert.Equal("2.1.3", package.Version);
|
||||||
|
Assert.Equal("pkg:npm/ms@2.1.3", package.Purl);
|
||||||
|
Assert.Equal("bun.lock", package.Source);
|
||||||
|
Assert.Equal("https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", package.Resolved);
|
||||||
|
Assert.Equal("sha512-6FlzubTLZG3J2a", package.Integrity);
|
||||||
|
Assert.True(package.IsDev);
|
||||||
|
Assert.False(package.IsOptional);
|
||||||
|
Assert.Equal("npm", package.SourceType);
|
||||||
|
Assert.Contains("debug", package.Dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region AddOccurrence Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddOccurrence_AddsDuplicatePath_DoesNotDuplicate()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
package.AddOccurrence("node_modules/lodash");
|
||||||
|
package.AddOccurrence("node_modules/lodash");
|
||||||
|
|
||||||
|
Assert.Single(package.OccurrencePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddOccurrence_AddsMultiplePaths_StoresAll()
|
||||||
|
{
|
||||||
|
var package = BunPackage.FromPackageJson(
|
||||||
|
name: "lodash",
|
||||||
|
version: "4.17.21",
|
||||||
|
logicalPath: "node_modules/lodash",
|
||||||
|
realPath: null,
|
||||||
|
isPrivate: false,
|
||||||
|
lockEntry: null);
|
||||||
|
|
||||||
|
package.AddOccurrence("node_modules/lodash");
|
||||||
|
package.AddOccurrence("packages/app/node_modules/lodash");
|
||||||
|
package.AddOccurrence("packages/lib/node_modules/lodash");
|
||||||
|
|
||||||
|
Assert.Equal(3, package.OccurrencePaths.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
|
||||||
|
|
||||||
|
public sealed class BunWorkspaceHelperTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempDir;
|
||||||
|
|
||||||
|
public BunWorkspaceHelperTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), $"bun-workspace-test-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_tempDir))
|
||||||
|
{
|
||||||
|
Directory.Delete(_tempDir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region ParseWorkspaceInfo Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_MissingPackageJson_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Empty(result.WorkspacePatterns);
|
||||||
|
Assert.Empty(result.WorkspacePaths);
|
||||||
|
Assert.Empty(result.DirectDependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_NoWorkspaces_ReturnsEmptyPatterns()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Empty(result.WorkspacePatterns);
|
||||||
|
Assert.Single(result.DirectDependencies);
|
||||||
|
Assert.True(result.DirectDependencies.ContainsKey("lodash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_ArrayFormatWorkspaces_ReturnsPatterns()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"workspaces": ["packages/*", "apps/*"]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.WorkspacePatterns.Count);
|
||||||
|
Assert.Contains("packages/*", result.WorkspacePatterns);
|
||||||
|
Assert.Contains("apps/*", result.WorkspacePatterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_ObjectFormatWorkspaces_ReturnsPatterns()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"workspaces": {
|
||||||
|
"packages": ["packages/*", "apps/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.WorkspacePatterns.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_ResolvesWorkspacePaths()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"workspaces": ["packages/*"]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
// Create workspace packages
|
||||||
|
var pkgADir = Path.Combine(_tempDir, "packages", "pkg-a");
|
||||||
|
Directory.CreateDirectory(pkgADir);
|
||||||
|
File.WriteAllText(Path.Combine(pkgADir, "package.json"), """{"name": "@my/pkg-a"}""");
|
||||||
|
|
||||||
|
var pkgBDir = Path.Combine(_tempDir, "packages", "pkg-b");
|
||||||
|
Directory.CreateDirectory(pkgBDir);
|
||||||
|
File.WriteAllText(Path.Combine(pkgBDir, "package.json"), """{"name": "@my/pkg-b"}""");
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.WorkspacePaths.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_ParsesAllDependencyTypes()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal(4, result.DirectDependencies.Count);
|
||||||
|
|
||||||
|
Assert.True(result.DirectDependencies.ContainsKey("lodash"));
|
||||||
|
Assert.Equal(BunWorkspaceHelper.DependencyType.Production, result.DirectDependencies["lodash"]);
|
||||||
|
|
||||||
|
Assert.True(result.DirectDependencies.ContainsKey("typescript"));
|
||||||
|
Assert.Equal(BunWorkspaceHelper.DependencyType.Dev, result.DirectDependencies["typescript"]);
|
||||||
|
|
||||||
|
Assert.True(result.DirectDependencies.ContainsKey("fsevents"));
|
||||||
|
Assert.Equal(BunWorkspaceHelper.DependencyType.Optional, result.DirectDependencies["fsevents"]);
|
||||||
|
|
||||||
|
Assert.True(result.DirectDependencies.ContainsKey("react"));
|
||||||
|
Assert.Equal(BunWorkspaceHelper.DependencyType.Peer, result.DirectDependencies["react"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_MergesDependencyFlags()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"lodash": "^4.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Single(result.DirectDependencies);
|
||||||
|
var depType = result.DirectDependencies["lodash"];
|
||||||
|
Assert.True((depType & BunWorkspaceHelper.DependencyType.Production) != 0);
|
||||||
|
Assert.True((depType & BunWorkspaceHelper.DependencyType.Peer) != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_ParsesPatchedDependencies()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"patchedDependencies": {
|
||||||
|
"lodash@4.17.21": "patches/lodash@4.17.21.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Single(result.PatchedDependencies);
|
||||||
|
Assert.True(result.PatchedDependencies.ContainsKey("lodash"));
|
||||||
|
Assert.Equal("patches/lodash@4.17.21.patch", result.PatchedDependencies["lodash"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_ScansPatchesDirectory()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
// Create patches directory with patch files
|
||||||
|
var patchesDir = Path.Combine(_tempDir, "patches");
|
||||||
|
Directory.CreateDirectory(patchesDir);
|
||||||
|
File.WriteAllText(Path.Combine(patchesDir, "lodash@4.17.21.patch"), "diff content");
|
||||||
|
File.WriteAllText(Path.Combine(patchesDir, "@babel+core@7.24.0.patch"), "diff content");
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.PatchedDependencies.Count);
|
||||||
|
Assert.True(result.PatchedDependencies.ContainsKey("lodash"));
|
||||||
|
Assert.True(result.PatchedDependencies.ContainsKey("@babel+core"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_ScansBunPatchesDirectory()
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "my-project"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||||
|
|
||||||
|
// Create .patches directory (Bun-specific)
|
||||||
|
var patchesDir = Path.Combine(_tempDir, ".patches");
|
||||||
|
Directory.CreateDirectory(patchesDir);
|
||||||
|
File.WriteAllText(Path.Combine(patchesDir, "ms@2.1.3.patch"), "diff content");
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Single(result.PatchedDependencies);
|
||||||
|
Assert.True(result.PatchedDependencies.ContainsKey("ms"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWorkspaceInfo_MalformedJson_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(_tempDir, "package.json"), "{ invalid json }");
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
|
||||||
|
|
||||||
|
Assert.Empty(result.DirectDependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region IsDirect Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsDirect_DirectDependency_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var deps = new Dictionary<string, BunWorkspaceHelper.DependencyType>
|
||||||
|
{
|
||||||
|
["lodash"] = BunWorkspaceHelper.DependencyType.Production
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.IsDirect("lodash", deps);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsDirect_TransitiveDependency_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var deps = new Dictionary<string, BunWorkspaceHelper.DependencyType>
|
||||||
|
{
|
||||||
|
["lodash"] = BunWorkspaceHelper.DependencyType.Production
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = BunWorkspaceHelper.IsDirect("ms", deps);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests.Internal;
|
||||||
|
|
||||||
|
public sealed class GoCgoDetectorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeGoFileContent_DetectsCgoImport()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <stdio.h>
|
||||||
|
*/
|
||||||
|
import ""C""
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
C.puts(C.CString(""Hello from C""))
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
|
||||||
|
|
||||||
|
Assert.True(result.HasCgoImport);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeGoFileContent_DetectsCgoDirectives()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -I/usr/local/include
|
||||||
|
#cgo LDFLAGS: -L/usr/local/lib -lpng
|
||||||
|
#cgo pkg-config: gtk+-3.0
|
||||||
|
#include <png.h>
|
||||||
|
*/
|
||||||
|
import ""C""
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
|
||||||
|
|
||||||
|
Assert.True(result.HasCgoImport);
|
||||||
|
Assert.Equal(3, result.Directives.Count);
|
||||||
|
|
||||||
|
var cflags = result.Directives.FirstOrDefault(d => d.Type == "CFLAGS");
|
||||||
|
Assert.NotNull(cflags);
|
||||||
|
Assert.Equal("-I/usr/local/include", cflags.Value);
|
||||||
|
|
||||||
|
var ldflags = result.Directives.FirstOrDefault(d => d.Type == "LDFLAGS");
|
||||||
|
Assert.NotNull(ldflags);
|
||||||
|
Assert.Equal("-L/usr/local/lib -lpng", ldflags.Value);
|
||||||
|
|
||||||
|
var pkgconfig = result.Directives.FirstOrDefault(d => d.Type == "pkg-config");
|
||||||
|
Assert.NotNull(pkgconfig);
|
||||||
|
Assert.Equal("gtk+-3.0", pkgconfig.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeGoFileContent_DetectsIncludedHeaders()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include ""custom.h""
|
||||||
|
*/
|
||||||
|
import ""C""
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
|
||||||
|
|
||||||
|
Assert.True(result.HasCgoImport);
|
||||||
|
Assert.Equal(3, result.Headers.Count);
|
||||||
|
Assert.Contains("stdio.h", result.Headers);
|
||||||
|
Assert.Contains("stdlib.h", result.Headers);
|
||||||
|
Assert.Contains("custom.h", result.Headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeGoFileContent_DetectsPlatformConstrainedDirectives()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo linux LDFLAGS: -lm
|
||||||
|
#cgo darwin LDFLAGS: -framework CoreFoundation
|
||||||
|
#cgo windows LDFLAGS: -lkernel32
|
||||||
|
*/
|
||||||
|
import ""C""
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
|
||||||
|
|
||||||
|
Assert.True(result.HasCgoImport);
|
||||||
|
Assert.Equal(3, result.Directives.Count);
|
||||||
|
|
||||||
|
var linuxLdflags = result.Directives.FirstOrDefault(d => d.Constraint?.Contains("linux") == true);
|
||||||
|
Assert.NotNull(linuxLdflags);
|
||||||
|
Assert.Equal("-lm", linuxLdflags.Value);
|
||||||
|
|
||||||
|
var darwinLdflags = result.Directives.FirstOrDefault(d => d.Constraint?.Contains("darwin") == true);
|
||||||
|
Assert.NotNull(darwinLdflags);
|
||||||
|
Assert.Equal("-framework CoreFoundation", darwinLdflags.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeGoFileContent_NoCgoImport_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
package main
|
||||||
|
|
||||||
|
import ""fmt""
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println(""Hello"")
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
|
||||||
|
|
||||||
|
Assert.False(result.HasCgoImport);
|
||||||
|
Assert.Empty(result.Directives);
|
||||||
|
Assert.Empty(result.Headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractFromBuildSettings_ExtractsCgoEnabled()
|
||||||
|
{
|
||||||
|
var settings = new List<KeyValuePair<string, string?>>
|
||||||
|
{
|
||||||
|
new("CGO_ENABLED", "1"),
|
||||||
|
new("CGO_CFLAGS", "-I/usr/include"),
|
||||||
|
new("CGO_LDFLAGS", "-L/usr/lib -lssl"),
|
||||||
|
new("CC", "gcc"),
|
||||||
|
new("CXX", "g++"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoCgoDetector.ExtractFromBuildSettings(settings);
|
||||||
|
|
||||||
|
Assert.True(result.CgoEnabled);
|
||||||
|
Assert.Equal("-I/usr/include", result.CgoFlags);
|
||||||
|
Assert.Equal("-L/usr/lib -lssl", result.CgoLdFlags);
|
||||||
|
Assert.Equal("gcc", result.CCompiler);
|
||||||
|
Assert.Equal("g++", result.CxxCompiler);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractFromBuildSettings_CgoDisabled_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var settings = new List<KeyValuePair<string, string?>>
|
||||||
|
{
|
||||||
|
new("CGO_ENABLED", "0"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoCgoDetector.ExtractFromBuildSettings(settings);
|
||||||
|
|
||||||
|
Assert.False(result.CgoEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractFromBuildSettings_NoSettings_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var settings = new List<KeyValuePair<string, string?>>();
|
||||||
|
|
||||||
|
var result = GoCgoDetector.ExtractFromBuildSettings(settings);
|
||||||
|
|
||||||
|
Assert.False(result.CgoEnabled);
|
||||||
|
Assert.True(result.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CgoAnalysisResult_GetCFlags_CombinesMultipleDirectives()
|
||||||
|
{
|
||||||
|
var directives = new[]
|
||||||
|
{
|
||||||
|
new GoCgoDetector.CgoDirective("CFLAGS", "-I/usr/include", null, "a.go"),
|
||||||
|
new GoCgoDetector.CgoDirective("CFLAGS", "-I/usr/local/include", null, "b.go"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new GoCgoDetector.CgoAnalysisResult(
|
||||||
|
true,
|
||||||
|
["a.go", "b.go"],
|
||||||
|
[.. directives],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
var cflags = result.GetCFlags();
|
||||||
|
|
||||||
|
Assert.NotNull(cflags);
|
||||||
|
Assert.Contains("-I/usr/include", cflags);
|
||||||
|
Assert.Contains("-I/usr/local/include", cflags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests.Internal;
|
||||||
|
|
||||||
|
public sealed class GoLicenseDetectorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsMitLicense()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Example Corp
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the ""Software""), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software...
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("MIT", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsApache2License()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("Apache-2.0", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsBsd3ClauseLicense()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2023, Example Corp
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("BSD-3-Clause", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsGpl3License()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("GPL-3.0-only", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsIscLicense()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright (c) 2023, Example Corp
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("ISC", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsUnlicense()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("Unlicense", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsSpdxIdentifier()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
Some license text here...
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("Apache-2.0", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.High, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsDualLicenseSpdx()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
// SPDX-License-Identifier: MIT OR Apache-2.0
|
||||||
|
|
||||||
|
Dual licensed under MIT and Apache 2.0
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("MIT OR Apache-2.0", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.High, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsMpl2License()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
Mozilla Public License Version 2.0
|
||||||
|
==================================
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
--------------
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("MPL-2.0", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_EmptyContent_ReturnsUnknown()
|
||||||
|
{
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent("");
|
||||||
|
|
||||||
|
Assert.False(result.IsDetected);
|
||||||
|
Assert.Null(result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.None, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_UnrecognizedContent_ReturnsUnknown()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
This is some custom proprietary license text that doesn't match any known patterns.
|
||||||
|
No redistribution allowed without express written permission.
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.False(result.IsDetected);
|
||||||
|
Assert.Null(result.SpdxIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_KeywordFallback_DetectsMit()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
Some text mentioning MIT but not in the standard format
|
||||||
|
This project is licensed under MIT terms
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
// Should detect MIT via keyword fallback with low confidence
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("MIT", result.SpdxIdentifier);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.Low, result.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LicenseInfo_Unknown_IsDetectedFalse()
|
||||||
|
{
|
||||||
|
var info = GoLicenseDetector.LicenseInfo.Unknown;
|
||||||
|
|
||||||
|
Assert.False(info.IsDetected);
|
||||||
|
Assert.Null(info.SpdxIdentifier);
|
||||||
|
Assert.Null(info.LicenseFile);
|
||||||
|
Assert.Equal(GoLicenseDetector.LicenseConfidence.None, info.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsCC0License()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
CC0 1.0 Universal
|
||||||
|
|
||||||
|
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||||
|
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
||||||
|
ATTORNEY-CLIENT RELATIONSHIP.
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("CC0-1.0", result.SpdxIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsZlibLicense()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
zlib License
|
||||||
|
|
||||||
|
This software is provided 'as-is', without any express or implied
|
||||||
|
warranty. In no event will the authors be held liable for any damages
|
||||||
|
arising from the use of this software.
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("Zlib", result.SpdxIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeLicenseContent_DetectsBoostLicense()
|
||||||
|
{
|
||||||
|
var content = @"
|
||||||
|
Boost Software License - Version 1.0 - August 17th, 2003
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person or organization
|
||||||
|
obtaining a copy of the software and accompanying documentation covered by
|
||||||
|
this license (the ""Software"") to use, reproduce, display, distribute,
|
||||||
|
execute, and transmit the Software...
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
|
||||||
|
|
||||||
|
Assert.True(result.IsDetected);
|
||||||
|
Assert.Equal("BSL-1.0", result.SpdxIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests.Internal;
|
||||||
|
|
||||||
|
public sealed class GoVersionConflictDetectorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IsPseudoVersion_DetectsPseudoVersions()
|
||||||
|
{
|
||||||
|
// Standard pseudo-version formats
|
||||||
|
Assert.True(GoVersionConflictDetector.IsPseudoVersion("v0.0.0-20210101120000-abcdef123456"));
|
||||||
|
Assert.True(GoVersionConflictDetector.IsPseudoVersion("v1.2.3-0.20210101120000-abcdef123456"));
|
||||||
|
Assert.True(GoVersionConflictDetector.IsPseudoVersion("v0.0.0-20230915143052-deadbeef1234"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPseudoVersion_RejectsRegularVersions()
|
||||||
|
{
|
||||||
|
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v1.0.0"));
|
||||||
|
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v1.2.3"));
|
||||||
|
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v0.1.0-alpha"));
|
||||||
|
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v2.0.0-beta.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_DetectsPseudoVersionConflict()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v0.0.0-20210101120000-abcdef123456",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Single(result.Conflicts);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictType.PseudoVersion, result.Conflicts[0].ConflictType);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.Medium, result.Conflicts[0].Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_DetectsReplaceOverrideConflict()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v1.0.0",
|
||||||
|
IsReplaced = true,
|
||||||
|
ReplacementPath = "github.com/fork/mod",
|
||||||
|
ReplacementVersion = "v1.1.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Single(result.Conflicts);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictType.ReplaceOverride, result.Conflicts[0].ConflictType);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.Low, result.Conflicts[0].Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_DetectsLocalReplacementAsHighSeverity()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v1.0.0",
|
||||||
|
IsReplaced = true,
|
||||||
|
ReplacementPath = "../local/mod",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Single(result.Conflicts);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictType.LocalReplacement, result.Conflicts[0].ConflictType);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.High, result.Conflicts[0].Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_DetectsExcludedVersionConflict()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v1.0.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var excludes = new List<GoModParser.GoModExclude>
|
||||||
|
{
|
||||||
|
new("github.com/example/mod", "v1.0.0"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
excludes,
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Single(result.Conflicts);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictType.ExcludedVersion, result.Conflicts[0].ConflictType);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.High, result.Conflicts[0].Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_DetectsMajorVersionMismatch()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v1.0.0",
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod/v2",
|
||||||
|
Version = "v2.0.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(2, result.Conflicts.Length);
|
||||||
|
Assert.All(result.Conflicts, c =>
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictType.MajorVersionMismatch, c.ConflictType));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_NoConflicts_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v1.0.0",
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/other/lib",
|
||||||
|
Version = "v2.1.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
Assert.False(result.HasConflicts);
|
||||||
|
Assert.Empty(result.Conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetConflict_ReturnsConflictForModule()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v0.0.0-20210101120000-abcdef123456",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
var conflict = result.GetConflict("github.com/example/mod");
|
||||||
|
|
||||||
|
Assert.NotNull(conflict);
|
||||||
|
Assert.Equal("github.com/example/mod", conflict.ModulePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetConflict_ReturnsNullForNonConflictingModule()
|
||||||
|
{
|
||||||
|
var modules = new List<GoSourceInventory.GoSourceModule>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Path = "github.com/example/mod",
|
||||||
|
Version = "v1.0.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.Analyze(
|
||||||
|
modules,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ImmutableArray<string>.Empty);
|
||||||
|
|
||||||
|
var conflict = result.GetConflict("github.com/example/mod");
|
||||||
|
|
||||||
|
Assert.Null(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalyzeWorkspace_DetectsCrossModuleConflicts()
|
||||||
|
{
|
||||||
|
var inventory1 = new GoSourceInventory.SourceInventoryResult(
|
||||||
|
"github.com/workspace/mod1",
|
||||||
|
"1.21",
|
||||||
|
[
|
||||||
|
new GoSourceInventory.GoSourceModule
|
||||||
|
{
|
||||||
|
Path = "github.com/shared/dep",
|
||||||
|
Version = "v1.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ImmutableArray<string>.Empty,
|
||||||
|
GoVersionConflictDetector.GoConflictAnalysis.Empty,
|
||||||
|
GoCgoDetector.CgoAnalysisResult.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
|
var inventory2 = new GoSourceInventory.SourceInventoryResult(
|
||||||
|
"github.com/workspace/mod2",
|
||||||
|
"1.21",
|
||||||
|
[
|
||||||
|
new GoSourceInventory.GoSourceModule
|
||||||
|
{
|
||||||
|
Path = "github.com/shared/dep",
|
||||||
|
Version = "v1.2.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ImmutableArray<string>.Empty,
|
||||||
|
GoVersionConflictDetector.GoConflictAnalysis.Empty,
|
||||||
|
GoCgoDetector.CgoAnalysisResult.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
|
var result = GoVersionConflictDetector.AnalyzeWorkspace([inventory1, inventory2]);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Single(result.Conflicts);
|
||||||
|
Assert.Equal(GoVersionConflictDetector.GoConflictType.WorkspaceConflict, result.Conflicts[0].ConflictType);
|
||||||
|
Assert.Contains("v1.0.0", result.Conflicts[0].RequestedVersions);
|
||||||
|
Assert.Contains("v1.2.0", result.Conflicts[0].RequestedVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
"usedByEntrypoint": false,
|
"usedByEntrypoint": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"entrypoint": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg/index.js",
|
"entrypoint": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg/index.js",
|
||||||
"path": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg"
|
"path": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg",
|
||||||
|
"riskLevel": "production",
|
||||||
|
"scope": "production"
|
||||||
},
|
},
|
||||||
"evidence": [
|
"evidence": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Conflicts;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Conflicts;
|
||||||
|
|
||||||
|
public class VersionConflictDetectorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_EmptyList_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = VersionConflictDetector.Analyze([]);
|
||||||
|
|
||||||
|
Assert.False(result.HasConflicts);
|
||||||
|
Assert.Equal(0, result.TotalConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.None, result.MaxSeverity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_SinglePackage_NoConflict()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("requests", "2.28.0", "/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.False(result.HasConflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_SameVersionMultipleLocations_NoConflict()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("requests", "2.28.0", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.False(result.HasConflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_DifferentVersions_DetectsConflict()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("requests", "2.31.0", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(1, result.TotalConflicts);
|
||||||
|
|
||||||
|
var conflict = result.Conflicts[0];
|
||||||
|
Assert.Equal("requests", conflict.NormalizedName);
|
||||||
|
Assert.Equal(2, conflict.UniqueVersions.Count());
|
||||||
|
Assert.Contains("2.28.0", conflict.UniqueVersions);
|
||||||
|
Assert.Contains("2.31.0", conflict.UniqueVersions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_MajorVersionDifference_HighSeverity()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("django", "3.2.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("django", "4.1.0", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.High, result.MaxSeverity);
|
||||||
|
Assert.Equal(ConflictSeverity.High, result.Conflicts[0].Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_MinorVersionDifference_MediumSeverity()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("flask", "2.1.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("flask", "2.3.0", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.Medium, result.MaxSeverity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_PatchVersionDifference_LowSeverity()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("pytest", "7.4.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("pytest", "7.4.3", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.Low, result.MaxSeverity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_EpochDifference_HighSeverity()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("pytz", "2023.3", "/env1/site-packages"),
|
||||||
|
CreatePackage("pytz", "1!2023.3", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.High, result.MaxSeverity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_NormalizesPackageNames()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("My-Package", "1.0.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("my_package", "2.0.0", "/env2/site-packages"),
|
||||||
|
CreatePackage("my.package", "3.0.0", "/env3/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(1, result.TotalConflicts);
|
||||||
|
Assert.Equal(3, result.Conflicts[0].UniqueVersions.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_PreReleaseVersions_Handled()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("numpy", "1.24.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("numpy", "1.25.0rc1", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.Medium, result.MaxSeverity); // Minor difference
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_LocalVersions_Handled()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("mypackage", "1.0.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("mypackage", "1.0.0+local.build", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
// Local versions are different but same base version
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_PostReleaseVersions_Handled()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("setuptools", "68.0.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("setuptools", "68.0.0.post1", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.Low, result.MaxSeverity); // Same micro, just post release
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_MultipleConflicts_AllDetected()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("requests", "2.31.0", "/env2/site-packages"),
|
||||||
|
CreatePackage("flask", "2.0.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("flask", "3.0.0", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(2, result.TotalConflicts);
|
||||||
|
Assert.Equal(ConflictSeverity.High, result.MaxSeverity); // Flask has major diff
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_PackagesWithoutVersion_Ignored()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("mypackage", null, "/env1/site-packages"),
|
||||||
|
CreatePackage("mypackage", "1.0.0", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.False(result.HasConflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetConflict_ReturnsSpecificConflict()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("requests", "2.31.0", "/env2/site-packages"),
|
||||||
|
CreatePackage("flask", "2.0.0", "/env1/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var conflict = VersionConflictDetector.GetConflict(packages, "requests");
|
||||||
|
|
||||||
|
Assert.NotNull(conflict);
|
||||||
|
Assert.Equal("requests", conflict.NormalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetConflict_NoConflict_ReturnsNull()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("flask", "2.0.0", "/env1/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var conflict = VersionConflictDetector.GetConflict(packages, "flask");
|
||||||
|
|
||||||
|
Assert.Null(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Conflict_PurlGeneration_Correct()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("my_package", "1.0.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("my_package", "2.0.0", "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
var conflict = result.Conflicts[0];
|
||||||
|
|
||||||
|
Assert.Equal("pkg:pypi/my-package", conflict.Purl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HighSeverityConflicts_FiltersCorrectly()
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("django", "3.0.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("django", "4.0.0", "/env2/site-packages"), // High
|
||||||
|
CreatePackage("flask", "2.0.0", "/env1/site-packages"),
|
||||||
|
CreatePackage("flask", "2.0.1", "/env2/site-packages") // Low
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.TotalConflicts);
|
||||||
|
Assert.Single(result.HighSeverityConflicts);
|
||||||
|
Assert.Equal("django", result.HighSeverityConflicts[0].NormalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1.0.0", "2.0.0", 3)] // ConflictSeverity.High
|
||||||
|
[InlineData("1.0.0", "1.1.0", 2)] // ConflictSeverity.Medium
|
||||||
|
[InlineData("1.0.0", "1.0.1", 1)] // ConflictSeverity.Low
|
||||||
|
[InlineData("1!1.0.0", "2!1.0.0", 3)] // ConflictSeverity.High (epoch diff)
|
||||||
|
[InlineData("1.0.0a1", "1.0.0", 1)] // ConflictSeverity.Low
|
||||||
|
public void Analyze_VersionPairs_CorrectSeverity(string v1, string v2, int expectedSeverity)
|
||||||
|
{
|
||||||
|
var packages = new[]
|
||||||
|
{
|
||||||
|
CreatePackage("testpkg", v1, "/env1/site-packages"),
|
||||||
|
CreatePackage("testpkg", v2, "/env2/site-packages")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = VersionConflictDetector.Analyze(packages);
|
||||||
|
var expected = (ConflictSeverity)expectedSeverity;
|
||||||
|
|
||||||
|
Assert.True(result.HasConflicts);
|
||||||
|
Assert.Equal(expected, result.Conflicts[0].Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PythonPackageInfo CreatePackage(string name, string? version, string location)
|
||||||
|
{
|
||||||
|
return new PythonPackageInfo(
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
Kind: PythonPackageKind.Wheel,
|
||||||
|
Location: location,
|
||||||
|
MetadataPath: $"{location}/{name.ToLowerInvariant()}-{version ?? "0.0.0"}.dist-info",
|
||||||
|
TopLevelModules: [],
|
||||||
|
Dependencies: [],
|
||||||
|
Extras: [],
|
||||||
|
RecordFiles: [],
|
||||||
|
InstallerTool: "pip",
|
||||||
|
EditableTarget: null,
|
||||||
|
IsDirectDependency: true,
|
||||||
|
Confidence: PythonPackageConfidence.High);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Licensing;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Licensing;
|
||||||
|
|
||||||
|
public class SpdxLicenseNormalizerTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MIT", "MIT")]
|
||||||
|
[InlineData("MIT License", "MIT")]
|
||||||
|
[InlineData("The MIT License", "MIT")]
|
||||||
|
[InlineData("mit", "MIT")]
|
||||||
|
public void NormalizeFromString_MitVariations(string input, string expected)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Apache", "Apache-2.0")]
|
||||||
|
[InlineData("Apache 2.0", "Apache-2.0")]
|
||||||
|
[InlineData("Apache-2.0", "Apache-2.0")]
|
||||||
|
[InlineData("Apache License 2.0", "Apache-2.0")]
|
||||||
|
[InlineData("Apache License, Version 2.0", "Apache-2.0")]
|
||||||
|
[InlineData("Apache Software License", "Apache-2.0")]
|
||||||
|
[InlineData("ASL 2.0", "Apache-2.0")]
|
||||||
|
public void NormalizeFromString_ApacheVariations(string input, string expected)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("BSD", "BSD-3-Clause")]
|
||||||
|
[InlineData("BSD License", "BSD-3-Clause")]
|
||||||
|
[InlineData("BSD-2-Clause", "BSD-2-Clause")]
|
||||||
|
[InlineData("BSD-3-Clause", "BSD-3-Clause")]
|
||||||
|
[InlineData("BSD 2-Clause", "BSD-2-Clause")]
|
||||||
|
[InlineData("BSD 3-Clause", "BSD-3-Clause")]
|
||||||
|
[InlineData("Simplified BSD", "BSD-2-Clause")]
|
||||||
|
[InlineData("New BSD", "BSD-3-Clause")]
|
||||||
|
public void NormalizeFromString_BsdVariations(string input, string expected)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("GPL", "GPL-3.0-only")]
|
||||||
|
[InlineData("GPLv2", "GPL-2.0-only")]
|
||||||
|
[InlineData("GPLv3", "GPL-3.0-only")]
|
||||||
|
[InlineData("GPL-2.0", "GPL-2.0-only")]
|
||||||
|
[InlineData("GPL-3.0", "GPL-3.0-only")]
|
||||||
|
[InlineData("GPL-2.0-only", "GPL-2.0-only")]
|
||||||
|
[InlineData("GPL-3.0-only", "GPL-3.0-only")]
|
||||||
|
[InlineData("GPL-2.0-or-later", "GPL-2.0-or-later")]
|
||||||
|
[InlineData("GPL-3.0-or-later", "GPL-3.0-or-later")]
|
||||||
|
public void NormalizeFromString_GplVariations(string input, string expected)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("LGPL", "LGPL-3.0-only")]
|
||||||
|
[InlineData("LGPLv2", "LGPL-2.0-only")]
|
||||||
|
[InlineData("LGPL-2.0", "LGPL-2.0-only")]
|
||||||
|
[InlineData("LGPL-2.1", "LGPL-2.1-only")]
|
||||||
|
[InlineData("LGPLv3", "LGPL-3.0-only")]
|
||||||
|
[InlineData("LGPL-3.0", "LGPL-3.0-only")]
|
||||||
|
public void NormalizeFromString_LgplVariations(string input, string expected)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MPL", "MPL-2.0")]
|
||||||
|
[InlineData("MPL-2.0", "MPL-2.0")]
|
||||||
|
[InlineData("Mozilla Public License 2.0", "MPL-2.0")]
|
||||||
|
public void NormalizeFromString_MplVariations(string input, string expected)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ISC", "ISC")]
|
||||||
|
[InlineData("ISC License", "ISC")]
|
||||||
|
[InlineData("Unlicense", "Unlicense")]
|
||||||
|
[InlineData("The Unlicense", "Unlicense")]
|
||||||
|
[InlineData("CC0", "CC0-1.0")]
|
||||||
|
[InlineData("CC0-1.0", "CC0-1.0")]
|
||||||
|
[InlineData("Public Domain", "Unlicense")]
|
||||||
|
[InlineData("Zlib", "Zlib")]
|
||||||
|
[InlineData("PSF", "PSF-2.0")]
|
||||||
|
public void NormalizeFromString_OtherLicenses(string input, string expected)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromClassifiers_MitClassifier()
|
||||||
|
{
|
||||||
|
var classifiers = new[] { "License :: OSI Approved :: MIT License" };
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
|
||||||
|
Assert.Equal("MIT", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromClassifiers_ApacheClassifier()
|
||||||
|
{
|
||||||
|
var classifiers = new[] { "License :: OSI Approved :: Apache Software License" };
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
|
||||||
|
Assert.Equal("Apache-2.0", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromClassifiers_MultipleLicenses_ReturnsOrExpression()
|
||||||
|
{
|
||||||
|
var classifiers = new[]
|
||||||
|
{
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"License :: OSI Approved :: Apache Software License"
|
||||||
|
};
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
|
||||||
|
// Should return "Apache-2.0 OR MIT" (alphabetically sorted)
|
||||||
|
Assert.Equal("Apache-2.0 OR MIT", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromClassifiers_NoLicenseClassifiers_ReturnsNull()
|
||||||
|
{
|
||||||
|
var classifiers = new[]
|
||||||
|
{
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Programming Language :: Python :: 3"
|
||||||
|
};
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalize_Pep639Expression_TakesPrecedence()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.Normalize(
|
||||||
|
license: "MIT",
|
||||||
|
classifiers: new[] { "License :: OSI Approved :: Apache Software License" },
|
||||||
|
licenseExpression: "GPL-3.0-only");
|
||||||
|
|
||||||
|
Assert.Equal("GPL-3.0-only", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalize_ClassifiersOverLicenseString()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.Normalize(
|
||||||
|
license: "Some custom license",
|
||||||
|
classifiers: new[] { "License :: OSI Approved :: MIT License" });
|
||||||
|
|
||||||
|
Assert.Equal("MIT", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalize_FallsBackToLicenseString()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.Normalize(
|
||||||
|
license: "MIT",
|
||||||
|
classifiers: new[] { "Programming Language :: Python :: 3" });
|
||||||
|
|
||||||
|
Assert.Equal("MIT", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalize_AllNull_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.Normalize(null, null, null);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "GPL-3.0-only")]
|
||||||
|
[InlineData("License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", "GPL-2.0-or-later")]
|
||||||
|
[InlineData("License :: OSI Approved :: BSD License", "BSD-3-Clause")]
|
||||||
|
public void NormalizeFromClassifiers_GplBsdClassifiers(string classifier, string expected)
|
||||||
|
{
|
||||||
|
var classifiers = new[] { classifier };
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromString_UnknownLicense_ReturnsLicenseRef()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString("Custom License v1.0");
|
||||||
|
Assert.StartsWith("LicenseRef-", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromString_Empty_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(SpdxLicenseNormalizer.NormalizeFromString(""));
|
||||||
|
Assert.Null(SpdxLicenseNormalizer.NormalizeFromString(" "));
|
||||||
|
Assert.Null(SpdxLicenseNormalizer.NormalizeFromString(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromString_VeryLongText_ReturnsNull()
|
||||||
|
{
|
||||||
|
var longText = new string('x', 200);
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString(longText);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromString_Url_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString("https://opensource.org/licenses/MIT");
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("GPL-2.0-only AND MIT", true)]
|
||||||
|
[InlineData("Apache-2.0 OR MIT", true)]
|
||||||
|
[InlineData("MIT WITH Classpath-exception-2.0", true)]
|
||||||
|
[InlineData("Apache-2.0", true)]
|
||||||
|
public void Normalize_ValidSpdxExpression_AcceptedAsPep639(string expression, bool isValid)
|
||||||
|
{
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.Normalize(null, null, expression);
|
||||||
|
Assert.Equal(expression, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromClassifiers_DuplicateLicenses_Deduplicated()
|
||||||
|
{
|
||||||
|
var classifiers = new[]
|
||||||
|
{
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"License :: OSI Approved :: MIT License", // duplicate
|
||||||
|
"Programming Language :: Python :: 3"
|
||||||
|
};
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
|
||||||
|
Assert.Equal("MIT", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromString_PatternMatch_GplWithVersion()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString("GNU General Public License v3");
|
||||||
|
Assert.Equal("GPL-3.0-only", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromString_PatternMatch_BsdWithClauses()
|
||||||
|
{
|
||||||
|
var result = SpdxLicenseNormalizer.NormalizeFromString("BSD 2-Clause License");
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Contains("BSD", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Vendoring;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Vendoring;
|
||||||
|
|
||||||
|
public class VendoredPackageDetectorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_NoVendorDirectory_ReturnsNotVendored()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/module.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsVendored);
|
||||||
|
Assert.Equal(VendoringConfidence.None, result.Confidence);
|
||||||
|
Assert.Empty(result.EmbeddedPackages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_WithVendorDirectory_DetectsVendoring()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/urllib3/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/urllib3/connection.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
Assert.True(result.Confidence >= VendoringConfidence.Low);
|
||||||
|
Assert.Contains(result.Markers, m => m.StartsWith("vendor-directory:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_ExtractsEmbeddedPackages()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/pip/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/certifi/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/urllib3/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/requests/__init__.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("pip", "23.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
Assert.True(result.EmbeddedCount >= 3);
|
||||||
|
|
||||||
|
var embeddedNames = result.EmbeddedPackages.Select(p => p.Name).ToList();
|
||||||
|
Assert.Contains("certifi", embeddedNames);
|
||||||
|
Assert.Contains("urllib3", embeddedNames);
|
||||||
|
Assert.Contains("requests", embeddedNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_KnownVendoredPackage_HighConfidence()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/pip/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/certifi/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/urllib3/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/packaging/__init__.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("pip", "23.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
Assert.Contains("known-vendored-package", result.Markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_DetectsThirdPartyPattern()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/third_party/__init__.py",
|
||||||
|
"/site-packages/mypackage/third_party/six.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
Assert.Contains(result.Markers, m => m.Contains("third_party"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_DetectsExternPattern()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/extern/__init__.py",
|
||||||
|
"/site-packages/mypackage/extern/six.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
Assert.Contains(result.Markers, m => m.Contains("extern"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_SkipsInternalDirectories()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/urllib3/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/__pycache__/cached.pyc",
|
||||||
|
"/site-packages/mypackage/_vendor/.hidden/__init__.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var embeddedNames = result.EmbeddedPackages.Select(p => p.Name).ToList();
|
||||||
|
Assert.DoesNotContain("__pycache__", embeddedNames);
|
||||||
|
Assert.DoesNotContain(".hidden", embeddedNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmbeddedPackage_GeneratesCorrectPurl()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/pip/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/urllib3/__init__.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("pip", "23.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var urllib3 = result.EmbeddedPackages.FirstOrDefault(p => p.Name == "urllib3");
|
||||||
|
Assert.NotNull(urllib3);
|
||||||
|
Assert.StartsWith("pkg:pypi/urllib3", urllib3.Purl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmbeddedPackage_GeneratesQualifiedName()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/pip/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/__init__.py",
|
||||||
|
"/site-packages/pip/_vendor/urllib3/__init__.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("pip", "23.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var urllib3 = result.EmbeddedPackages.FirstOrDefault(p => p.Name == "urllib3");
|
||||||
|
Assert.NotNull(urllib3);
|
||||||
|
Assert.Equal("pip._vendor.urllib3", urllib3.QualifiedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_RecordEntriesWithVendor_AddsMarker()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/six.py");
|
||||||
|
|
||||||
|
var recordFiles = ImmutableArray.Create(
|
||||||
|
new PythonRecordEntry("mypackage/__init__.py", "sha256=abc", 100),
|
||||||
|
new PythonRecordEntry("mypackage/_vendor/__init__.py", "sha256=def", 50),
|
||||||
|
new PythonRecordEntry("mypackage/_vendor/six.py", "sha256=ghi", 500));
|
||||||
|
|
||||||
|
var package = CreatePackageWithRecords("mypackage", "1.0.0", "/site-packages", recordFiles);
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
Assert.Contains("record-vendor-entries", result.Markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_MultipleVendorDirectories_DetectsAll()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/six.py",
|
||||||
|
"/site-packages/mypackage/extern/__init__.py",
|
||||||
|
"/site-packages/mypackage/extern/toml.py");
|
||||||
|
|
||||||
|
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
Assert.True(result.VendorPaths.Length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analyze_SingleFileModule_Detected()
|
||||||
|
{
|
||||||
|
var vfs = CreateMockVfs(
|
||||||
|
"/site-packages/mypackage/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/__init__.py",
|
||||||
|
"/site-packages/mypackage/_vendor/six.py"); // Single file module
|
||||||
|
|
||||||
|
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
|
||||||
|
|
||||||
|
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsVendored);
|
||||||
|
var embeddedNames = result.EmbeddedPackages.Select(p => p.Name).ToList();
|
||||||
|
Assert.Contains("six", embeddedNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotVendored_ReturnsEmptyAnalysis()
|
||||||
|
{
|
||||||
|
var analysis = VendoringAnalysis.NotVendored("testpkg");
|
||||||
|
|
||||||
|
Assert.Equal("testpkg", analysis.PackageName);
|
||||||
|
Assert.False(analysis.IsVendored);
|
||||||
|
Assert.Equal(VendoringConfidence.None, analysis.Confidence);
|
||||||
|
Assert.Empty(analysis.Markers);
|
||||||
|
Assert.Empty(analysis.EmbeddedPackages);
|
||||||
|
Assert.Empty(analysis.VendorPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetEmbeddedPackageList_FormatsCorrectly()
|
||||||
|
{
|
||||||
|
var analysis = new VendoringAnalysis(
|
||||||
|
"pip",
|
||||||
|
true,
|
||||||
|
VendoringConfidence.High,
|
||||||
|
["vendor-directory:_vendor"],
|
||||||
|
[
|
||||||
|
new EmbeddedPackage("urllib3", "2.0.0", null, "/pip/_vendor/urllib3", "pip"),
|
||||||
|
new EmbeddedPackage("certifi", "2023.7.22", null, "/pip/_vendor/certifi", "pip")
|
||||||
|
],
|
||||||
|
["/pip/_vendor"]);
|
||||||
|
|
||||||
|
var list = analysis.GetEmbeddedPackageList();
|
||||||
|
|
||||||
|
Assert.Contains("certifi@2023.7.22", list);
|
||||||
|
Assert.Contains("urllib3@2.0.0", list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PythonVirtualFileSystem CreateMockVfs(params string[] filePaths)
|
||||||
|
{
|
||||||
|
var builder = PythonVirtualFileSystem.CreateBuilder();
|
||||||
|
|
||||||
|
foreach (var path in filePaths)
|
||||||
|
{
|
||||||
|
// Normalize path - remove leading slash for the builder
|
||||||
|
var normalizedPath = path.TrimStart('/');
|
||||||
|
builder.AddFile(
|
||||||
|
normalizedPath,
|
||||||
|
path, // Use original as absolute path for testing
|
||||||
|
PythonFileSource.SitePackages,
|
||||||
|
size: 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PythonPackageInfo CreatePackage(string name, string version, string location)
|
||||||
|
{
|
||||||
|
return new PythonPackageInfo(
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
Kind: PythonPackageKind.Wheel,
|
||||||
|
Location: location.TrimStart('/'),
|
||||||
|
MetadataPath: $"{location.TrimStart('/')}/{name.ToLowerInvariant()}-{version}.dist-info",
|
||||||
|
TopLevelModules: [name.ToLowerInvariant()],
|
||||||
|
Dependencies: [],
|
||||||
|
Extras: [],
|
||||||
|
RecordFiles: [],
|
||||||
|
InstallerTool: "pip",
|
||||||
|
EditableTarget: null,
|
||||||
|
IsDirectDependency: true,
|
||||||
|
Confidence: PythonPackageConfidence.High);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PythonPackageInfo CreatePackageWithRecords(
|
||||||
|
string name,
|
||||||
|
string version,
|
||||||
|
string location,
|
||||||
|
ImmutableArray<PythonRecordEntry> records)
|
||||||
|
{
|
||||||
|
return new PythonPackageInfo(
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
Kind: PythonPackageKind.Wheel,
|
||||||
|
Location: location.TrimStart('/'),
|
||||||
|
MetadataPath: $"{location.TrimStart('/')}/{name.ToLowerInvariant()}-{version}.dist-info",
|
||||||
|
TopLevelModules: [name.ToLowerInvariant()],
|
||||||
|
Dependencies: [],
|
||||||
|
Extras: [],
|
||||||
|
RecordFiles: records,
|
||||||
|
InstallerTool: "pip",
|
||||||
|
EditableTarget: null,
|
||||||
|
IsDirectDependency: true,
|
||||||
|
Confidence: PythonPackageConfidence.High);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -321,4 +321,106 @@ public class ElfDynamicSectionParserTests
|
|||||||
buffer[offset + bytes.Length] = 0; // null terminator
|
buffer[offset + bytes.Length] = 0; // null terminator
|
||||||
return bytes.Length;
|
return bytes.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesElfWithVersionNeeds()
|
||||||
|
{
|
||||||
|
// Test that version needs (GLIBC_2.17, etc.) are properly extracted
|
||||||
|
var buffer = new byte[4096];
|
||||||
|
SetupElf64Header(buffer, littleEndian: true);
|
||||||
|
|
||||||
|
// String table at offset 0x400
|
||||||
|
var strtab = 0x400;
|
||||||
|
var libcOffset = 1; // "libc.so.6"
|
||||||
|
var glibc217Offset = libcOffset + WriteString(buffer, strtab + libcOffset, "libc.so.6") + 1;
|
||||||
|
var glibc228Offset = glibc217Offset + WriteString(buffer, strtab + glibc217Offset, "GLIBC_2.17") + 1;
|
||||||
|
var strtabSize = glibc228Offset + WriteString(buffer, strtab + glibc228Offset, "GLIBC_2.28") + 1;
|
||||||
|
|
||||||
|
// Section headers at offset 0x800
|
||||||
|
var shoff = 0x800;
|
||||||
|
var shentsize = 64;
|
||||||
|
var shnum = 3; // null + .dynstr + .gnu.version_r
|
||||||
|
|
||||||
|
BitConverter.GetBytes((ulong)shoff).CopyTo(buffer, 40);
|
||||||
|
BitConverter.GetBytes((ushort)shentsize).CopyTo(buffer, 58);
|
||||||
|
BitConverter.GetBytes((ushort)shnum).CopyTo(buffer, 60);
|
||||||
|
|
||||||
|
// Section header 0: null
|
||||||
|
// Section header 1: .dynstr
|
||||||
|
var sh1 = shoff + shentsize;
|
||||||
|
BitConverter.GetBytes((uint)3).CopyTo(buffer, sh1 + 4); // sh_type = SHT_STRTAB
|
||||||
|
BitConverter.GetBytes((ulong)0x400).CopyTo(buffer, sh1 + 16); // sh_addr
|
||||||
|
BitConverter.GetBytes((ulong)strtab).CopyTo(buffer, sh1 + 24); // sh_offset
|
||||||
|
BitConverter.GetBytes((ulong)strtabSize).CopyTo(buffer, sh1 + 32); // sh_size
|
||||||
|
|
||||||
|
// Section header 2: .gnu.version_r (SHT_GNU_verneed = 0x6ffffffe)
|
||||||
|
var verneedFileOffset = 0x600;
|
||||||
|
var sh2 = shoff + shentsize * 2;
|
||||||
|
BitConverter.GetBytes((uint)0x6ffffffe).CopyTo(buffer, sh2 + 4); // sh_type = SHT_GNU_verneed
|
||||||
|
BitConverter.GetBytes((ulong)0x600).CopyTo(buffer, sh2 + 16); // sh_addr (vaddr)
|
||||||
|
BitConverter.GetBytes((ulong)verneedFileOffset).CopyTo(buffer, sh2 + 24); // sh_offset
|
||||||
|
|
||||||
|
// Version needs section at offset 0x600
|
||||||
|
// Verneed entry for libc.so.6 with two version requirements
|
||||||
|
// Elf64_Verneed: vn_version(2), vn_cnt(2), vn_file(4), vn_aux(4), vn_next(4)
|
||||||
|
var verneedOffset = verneedFileOffset;
|
||||||
|
BitConverter.GetBytes((ushort)1).CopyTo(buffer, verneedOffset); // vn_version = 1
|
||||||
|
BitConverter.GetBytes((ushort)2).CopyTo(buffer, verneedOffset + 2); // vn_cnt = 2 aux entries
|
||||||
|
BitConverter.GetBytes((uint)libcOffset).CopyTo(buffer, verneedOffset + 4); // vn_file -> "libc.so.6"
|
||||||
|
BitConverter.GetBytes((uint)16).CopyTo(buffer, verneedOffset + 8); // vn_aux = 16 (offset to first aux)
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, verneedOffset + 12); // vn_next = 0 (last entry)
|
||||||
|
|
||||||
|
// Vernaux entries
|
||||||
|
// Elf64_Vernaux: vna_hash(4), vna_flags(2), vna_other(2), vna_name(4), vna_next(4)
|
||||||
|
var aux1Offset = verneedOffset + 16;
|
||||||
|
BitConverter.GetBytes((uint)0x0d696910).CopyTo(buffer, aux1Offset); // vna_hash for GLIBC_2.17
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, aux1Offset + 4); // vna_flags
|
||||||
|
BitConverter.GetBytes((ushort)2).CopyTo(buffer, aux1Offset + 6); // vna_other
|
||||||
|
BitConverter.GetBytes((uint)glibc217Offset).CopyTo(buffer, aux1Offset + 8); // vna_name -> "GLIBC_2.17"
|
||||||
|
BitConverter.GetBytes((uint)16).CopyTo(buffer, aux1Offset + 12); // vna_next = 16 (offset to next aux)
|
||||||
|
|
||||||
|
var aux2Offset = aux1Offset + 16;
|
||||||
|
BitConverter.GetBytes((uint)0x09691974).CopyTo(buffer, aux2Offset); // vna_hash for GLIBC_2.28
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, aux2Offset + 4);
|
||||||
|
BitConverter.GetBytes((ushort)3).CopyTo(buffer, aux2Offset + 6);
|
||||||
|
BitConverter.GetBytes((uint)glibc228Offset).CopyTo(buffer, aux2Offset + 8); // vna_name -> "GLIBC_2.28"
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, aux2Offset + 12); // vna_next = 0 (last aux)
|
||||||
|
|
||||||
|
// Dynamic section at offset 0x200
|
||||||
|
var dynOffset = 0x200;
|
||||||
|
var dynEntrySize = 16;
|
||||||
|
var dynIndex = 0;
|
||||||
|
|
||||||
|
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 5, 0x400); // DT_STRTAB
|
||||||
|
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 10, (ulong)strtabSize); // DT_STRSZ
|
||||||
|
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 1, (ulong)libcOffset); // DT_NEEDED -> libc.so.6
|
||||||
|
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 0x6ffffffe, 0x600); // DT_VERNEED (vaddr)
|
||||||
|
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 0x6fffffff, 1); // DT_VERNEEDNUM = 1
|
||||||
|
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex, 0, 0); // DT_NULL
|
||||||
|
|
||||||
|
var dynSize = dynEntrySize * (dynIndex + 1);
|
||||||
|
|
||||||
|
// Program header
|
||||||
|
var phoff = 0x40;
|
||||||
|
var phentsize = 56;
|
||||||
|
var phnum = 1;
|
||||||
|
|
||||||
|
BitConverter.GetBytes((ulong)phoff).CopyTo(buffer, 32);
|
||||||
|
BitConverter.GetBytes((ushort)phentsize).CopyTo(buffer, 54);
|
||||||
|
BitConverter.GetBytes((ushort)phnum).CopyTo(buffer, 56);
|
||||||
|
|
||||||
|
BitConverter.GetBytes((uint)2).CopyTo(buffer, phoff); // PT_DYNAMIC
|
||||||
|
BitConverter.GetBytes((ulong)dynOffset).CopyTo(buffer, phoff + 8);
|
||||||
|
BitConverter.GetBytes((ulong)dynSize).CopyTo(buffer, phoff + 32);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(buffer);
|
||||||
|
var result = ElfDynamicSectionParser.TryParse(stream, out var info);
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
info.Dependencies.Should().HaveCount(1);
|
||||||
|
info.Dependencies[0].Soname.Should().Be("libc.so.6");
|
||||||
|
info.Dependencies[0].VersionNeeds.Should().HaveCount(2);
|
||||||
|
info.Dependencies[0].VersionNeeds.Should().Contain(v => v.Version == "GLIBC_2.17");
|
||||||
|
info.Dependencies[0].VersionNeeds.Should().Contain(v => v.Version == "GLIBC_2.28");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,4 +275,226 @@ public class PeImportParserTests
|
|||||||
""";
|
""";
|
||||||
Encoding.UTF8.GetBytes(manifestXml).CopyTo(buffer, 0x1000);
|
Encoding.UTF8.GetBytes(manifestXml).CopyTo(buffer, 0x1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesPe32PlusWithImportThunks()
|
||||||
|
{
|
||||||
|
// Test that 64-bit PE files correctly parse 8-byte import thunks
|
||||||
|
var buffer = new byte[8192];
|
||||||
|
SetupPe32PlusHeaderWithImports(buffer);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(buffer);
|
||||||
|
var result = PeImportParser.TryParse(stream, out var info);
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
info.Is64Bit.Should().BeTrue();
|
||||||
|
info.Dependencies.Should().HaveCount(1);
|
||||||
|
info.Dependencies[0].DllName.Should().Be("kernel32.dll");
|
||||||
|
// Verify function names are parsed correctly with 8-byte thunks
|
||||||
|
info.Dependencies[0].ImportedFunctions.Should().Contain("GetProcAddress");
|
||||||
|
info.Dependencies[0].ImportedFunctions.Should().Contain("LoadLibraryA");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetupPe32PlusHeaderWithImports(byte[] buffer)
|
||||||
|
{
|
||||||
|
// DOS header
|
||||||
|
buffer[0] = (byte)'M';
|
||||||
|
buffer[1] = (byte)'Z';
|
||||||
|
BitConverter.GetBytes(0x80).CopyTo(buffer, 0x3C); // e_lfanew
|
||||||
|
|
||||||
|
// PE signature
|
||||||
|
var peOffset = 0x80;
|
||||||
|
buffer[peOffset] = (byte)'P';
|
||||||
|
buffer[peOffset + 1] = (byte)'E';
|
||||||
|
|
||||||
|
// COFF header
|
||||||
|
BitConverter.GetBytes((ushort)0x8664).CopyTo(buffer, peOffset + 4); // Machine = x86_64
|
||||||
|
BitConverter.GetBytes((ushort)2).CopyTo(buffer, peOffset + 6); // NumberOfSections
|
||||||
|
BitConverter.GetBytes((ushort)0xF0).CopyTo(buffer, peOffset + 20); // SizeOfOptionalHeader (PE32+)
|
||||||
|
|
||||||
|
// Optional header (PE32+)
|
||||||
|
var optHeaderOffset = peOffset + 24;
|
||||||
|
BitConverter.GetBytes((ushort)0x20b).CopyTo(buffer, optHeaderOffset); // Magic = PE32+
|
||||||
|
BitConverter.GetBytes((ushort)PeSubsystem.WindowsConsole).CopyTo(buffer, optHeaderOffset + 68); // Subsystem
|
||||||
|
BitConverter.GetBytes((uint)16).CopyTo(buffer, optHeaderOffset + 108); // NumberOfRvaAndSizes
|
||||||
|
|
||||||
|
// Data directory - Import Directory (entry 1)
|
||||||
|
var dataDirOffset = optHeaderOffset + 112;
|
||||||
|
BitConverter.GetBytes((uint)0x2000).CopyTo(buffer, dataDirOffset + 8); // Import Directory RVA
|
||||||
|
BitConverter.GetBytes((uint)40).CopyTo(buffer, dataDirOffset + 12); // Import Directory Size
|
||||||
|
|
||||||
|
// Section headers
|
||||||
|
var sectionOffset = optHeaderOffset + 0xF0;
|
||||||
|
|
||||||
|
// .text section
|
||||||
|
".text\0\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8); // VirtualSize
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 12); // VirtualAddress
|
||||||
|
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 16); // SizeOfRawData
|
||||||
|
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 20); // PointerToRawData
|
||||||
|
|
||||||
|
// .idata section
|
||||||
|
sectionOffset += 40;
|
||||||
|
".idata\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8); // VirtualSize
|
||||||
|
BitConverter.GetBytes((uint)0x2000).CopyTo(buffer, sectionOffset + 12); // VirtualAddress
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 16); // SizeOfRawData
|
||||||
|
BitConverter.GetBytes((uint)0x400).CopyTo(buffer, sectionOffset + 20); // PointerToRawData
|
||||||
|
|
||||||
|
// Import descriptor at file offset 0x400 (RVA 0x2000)
|
||||||
|
var importOffset = 0x400;
|
||||||
|
BitConverter.GetBytes((uint)0x2080).CopyTo(buffer, importOffset); // OriginalFirstThunk RVA
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, importOffset + 4); // TimeDateStamp
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, importOffset + 8); // ForwarderChain
|
||||||
|
BitConverter.GetBytes((uint)0x2100).CopyTo(buffer, importOffset + 12); // Name RVA
|
||||||
|
BitConverter.GetBytes((uint)0x2080).CopyTo(buffer, importOffset + 16); // FirstThunk
|
||||||
|
|
||||||
|
// Null terminator for import directory
|
||||||
|
// (already zero at importOffset + 20)
|
||||||
|
|
||||||
|
// Import Lookup Table (ILT) / Import Name Table at RVA 0x2080 -> file offset 0x480
|
||||||
|
// PE32+ uses 8-byte entries!
|
||||||
|
var iltOffset = 0x480;
|
||||||
|
// Entry 1: Import by name, hint-name RVA = 0x2120
|
||||||
|
BitConverter.GetBytes((ulong)0x2120).CopyTo(buffer, iltOffset);
|
||||||
|
// Entry 2: Import by name, hint-name RVA = 0x2140
|
||||||
|
BitConverter.GetBytes((ulong)0x2140).CopyTo(buffer, iltOffset + 8);
|
||||||
|
// Null terminator (8 bytes of zero)
|
||||||
|
// (already zero)
|
||||||
|
|
||||||
|
// DLL name at RVA 0x2100 -> file offset 0x500
|
||||||
|
"kernel32.dll\0"u8.CopyTo(buffer.AsSpan(0x500));
|
||||||
|
|
||||||
|
// Hint-Name table entries
|
||||||
|
// Entry 1 at RVA 0x2120 -> file offset 0x520
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, 0x520); // Hint
|
||||||
|
"GetProcAddress\0"u8.CopyTo(buffer.AsSpan(0x522));
|
||||||
|
|
||||||
|
// Entry 2 at RVA 0x2140 -> file offset 0x540
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, 0x540); // Hint
|
||||||
|
"LoadLibraryA\0"u8.CopyTo(buffer.AsSpan(0x542));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesPeWithEmbeddedResourceManifest()
|
||||||
|
{
|
||||||
|
// Test that manifest is properly extracted from PE resources
|
||||||
|
var buffer = new byte[16384];
|
||||||
|
SetupPe32HeaderWithResourceManifest(buffer);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(buffer);
|
||||||
|
var result = PeImportParser.TryParse(stream, out var info);
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
info.SxsDependencies.Should().HaveCountGreaterOrEqualTo(1);
|
||||||
|
info.SxsDependencies.Should().Contain(d => d.Name == "Microsoft.VC90.CRT");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetupPe32HeaderWithResourceManifest(byte[] buffer)
|
||||||
|
{
|
||||||
|
// DOS header
|
||||||
|
buffer[0] = (byte)'M';
|
||||||
|
buffer[1] = (byte)'Z';
|
||||||
|
BitConverter.GetBytes(0x80).CopyTo(buffer, 0x3C);
|
||||||
|
|
||||||
|
// PE signature
|
||||||
|
var peOffset = 0x80;
|
||||||
|
buffer[peOffset] = (byte)'P';
|
||||||
|
buffer[peOffset + 1] = (byte)'E';
|
||||||
|
|
||||||
|
// COFF header
|
||||||
|
BitConverter.GetBytes((ushort)0x8664).CopyTo(buffer, peOffset + 4);
|
||||||
|
BitConverter.GetBytes((ushort)2).CopyTo(buffer, peOffset + 6); // 2 sections
|
||||||
|
BitConverter.GetBytes((ushort)0xE0).CopyTo(buffer, peOffset + 20);
|
||||||
|
|
||||||
|
// Optional header (PE32)
|
||||||
|
var optHeaderOffset = peOffset + 24;
|
||||||
|
BitConverter.GetBytes((ushort)0x10b).CopyTo(buffer, optHeaderOffset);
|
||||||
|
BitConverter.GetBytes((ushort)PeSubsystem.WindowsConsole).CopyTo(buffer, optHeaderOffset + 68);
|
||||||
|
BitConverter.GetBytes((uint)16).CopyTo(buffer, optHeaderOffset + 92);
|
||||||
|
|
||||||
|
// Data directory - Resource Directory (entry 2)
|
||||||
|
var dataDirOffset = optHeaderOffset + 96;
|
||||||
|
BitConverter.GetBytes((uint)0x3000).CopyTo(buffer, dataDirOffset + 16); // Resource Directory RVA
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, dataDirOffset + 20); // Resource Directory Size
|
||||||
|
|
||||||
|
// Section headers
|
||||||
|
var sectionOffset = optHeaderOffset + 0xE0;
|
||||||
|
|
||||||
|
// .text section
|
||||||
|
".text\0\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8);
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 12);
|
||||||
|
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 16);
|
||||||
|
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 20);
|
||||||
|
|
||||||
|
// .rsrc section
|
||||||
|
sectionOffset += 40;
|
||||||
|
".rsrc\0\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8);
|
||||||
|
BitConverter.GetBytes((uint)0x3000).CopyTo(buffer, sectionOffset + 12); // VirtualAddress
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 16);
|
||||||
|
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 20); // PointerToRawData
|
||||||
|
|
||||||
|
// Resource directory at file offset 0x1000 (RVA 0x3000)
|
||||||
|
var rsrcBase = 0x1000;
|
||||||
|
|
||||||
|
// Root directory (Type level)
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, rsrcBase); // Characteristics
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, rsrcBase + 4); // TimeDateStamp
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, rsrcBase + 8); // MajorVersion
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, rsrcBase + 10); // MinorVersion
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, rsrcBase + 12); // NumberOfNamedEntries
|
||||||
|
BitConverter.GetBytes((ushort)1).CopyTo(buffer, rsrcBase + 14); // NumberOfIdEntries
|
||||||
|
|
||||||
|
// Entry for RT_MANIFEST (ID=24) at offset 16
|
||||||
|
BitConverter.GetBytes((uint)24).CopyTo(buffer, rsrcBase + 16); // ID = RT_MANIFEST
|
||||||
|
BitConverter.GetBytes((uint)(0x80000000 | 0x30)).CopyTo(buffer, rsrcBase + 20); // Offset to subdirectory (high bit set)
|
||||||
|
|
||||||
|
// Name/ID subdirectory at offset 0x30
|
||||||
|
var nameDir = rsrcBase + 0x30;
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, nameDir);
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, nameDir + 4);
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, nameDir + 8);
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, nameDir + 10);
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, nameDir + 12);
|
||||||
|
BitConverter.GetBytes((ushort)1).CopyTo(buffer, nameDir + 14);
|
||||||
|
|
||||||
|
// Entry for ID=1 (application manifest)
|
||||||
|
BitConverter.GetBytes((uint)1).CopyTo(buffer, nameDir + 16);
|
||||||
|
BitConverter.GetBytes((uint)(0x80000000 | 0x50)).CopyTo(buffer, nameDir + 20); // Offset to language subdirectory
|
||||||
|
|
||||||
|
// Language subdirectory at offset 0x50
|
||||||
|
var langDir = rsrcBase + 0x50;
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, langDir);
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, langDir + 4);
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, langDir + 8);
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, langDir + 10);
|
||||||
|
BitConverter.GetBytes((ushort)0).CopyTo(buffer, langDir + 12);
|
||||||
|
BitConverter.GetBytes((ushort)1).CopyTo(buffer, langDir + 14);
|
||||||
|
|
||||||
|
// Entry for language (e.g., 0x409 = English US)
|
||||||
|
BitConverter.GetBytes((uint)0x409).CopyTo(buffer, langDir + 16);
|
||||||
|
BitConverter.GetBytes((uint)0x70).CopyTo(buffer, langDir + 20); // Offset to data entry (no high bit = data entry)
|
||||||
|
|
||||||
|
// Data entry at offset 0x70
|
||||||
|
var dataEntry = rsrcBase + 0x70;
|
||||||
|
BitConverter.GetBytes((uint)0x3100).CopyTo(buffer, dataEntry); // Data RVA
|
||||||
|
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, dataEntry + 4); // Data Size
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, dataEntry + 8); // CodePage
|
||||||
|
BitConverter.GetBytes((uint)0).CopyTo(buffer, dataEntry + 12); // Reserved
|
||||||
|
|
||||||
|
// Manifest data at RVA 0x3100 -> file offset 0x1100
|
||||||
|
var manifestXml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.VC90.CRT" version="9.0.21022.8" processorArchitecture="amd64" publicKeyToken="1fc8b3b9a1e18e3b"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
</assembly>
|
||||||
|
""";
|
||||||
|
Encoding.UTF8.GetBytes(manifestXml).CopyTo(buffer, 0x1100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user